diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ef879a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to Rstring + +Thanks for your interest in contributing! Here's how to get started: + +## Development Setup + +1. **Clone and setup development environment**: + ```bash + git clone https://github.com/tnunamak/rstring.git + cd rstring + python -m venv venv + source venv/bin/activate # Windows: venv\Scripts\activate + ``` + +2. **Install for development**: + ```bash + pip install -e . + pip install -r requirements-dev.txt + ``` + +3. **Run tests**: + ```bash + pytest + ``` + +4. **Use development version**: + ```bash + # With dev environment activated + rstring [options] # Uses your development code + ``` + +5. **Switch between versions**: + ```bash + # Use development version + source venv/bin/activate + rstring [options] + + # Use production version + deactivate + rstring [options] # Uses globally installed version + ``` + +## Guidelines + +- Fork the repo and create your branch from `main` +- Add tests for new functionality +- Ensure all tests pass +- Keep the focused scope: efficient code stringification for AI assistants +- Follow existing code style + +## Questions? + +Open an issue for discussion before major changes. \ No newline at end of file diff --git a/README.md b/README.md index 39a319f..e36e833 100644 --- a/README.md +++ b/README.md @@ -51,21 +51,24 @@ For more detailed information about pipx and its usage, refer to the [pipx docum Basic usage: ```bash -rstring # Use the default preset +rstring # All files (filtered by .gitignore) ``` +Work with different directories: ```bash -rstring --include=*/ --include=*.py --exclude=* # traverse all dirs, include .py files, exclude everything else +rstring /path/to/project # Analyze a specific directory +rstring -C /path/to/project # Change directory before processing ``` -Get help: +Custom filtering: ```bash -rstring --help +rstring --include='*.py' # Only Python files +rstring --include='*/' --include='*.js' --exclude='test*' # Complex patterns ``` -Use a specific preset: +Get help: ```bash -rstring --preset my_preset +rstring --help ``` Get a tree view of selected files: @@ -75,12 +78,44 @@ rstring --summary ## Advanced Usage -### Custom Presets +### Custom Filtering + +Rstring uses rsync's powerful include/exclude patterns: -Create a new preset: ```bash -rstring --save-preset python --include=*/ --include=*.py --exclude=* # save it -rstring --preset python # use it +# Include only Python files +rstring --include='*/' --include='*.py' --exclude='*' + +# Include web development files, exclude tests +rstring --include='*/' --include='*.{js,css,html}' --exclude='test*' --exclude='*' + +# Include documentation +rstring --include='*/' --include='*.md' --include='*.rst' --exclude='*' +``` + +### Creating Custom Shortcuts + +For frequently used patterns, create shell aliases in your `.bashrc` or `.zshrc`: + +```bash +# Python source files only +alias rstring-py="rstring --include='*/' --include='*.py' --exclude='test*'" + +# Web development files +alias rstring-web="rstring --include='*/' --include='*.{js,ts,css,html}' --exclude='node_modules/'" + +# Documentation files +alias rstring-docs="rstring --include='*/' --include='*.{md,rst,txt}' --exclude='*'" + +# All source code (no tests, docs, or config) +alias rstring-src="rstring --include='src/' --include='lib/' --exclude='*'" +``` + +Usage: +```bash +rstring-py # Python files in current directory +rstring-web -C /path/to/app # Web files in different directory +rstring-docs --summary # Documentation with tree view ``` ### File Preview @@ -90,7 +125,6 @@ Limit output to first N lines of each file: rstring --preview-length=10 ``` - ### Gitignore Integration By default, Rstring automatically excludes .gitignore patterns. To ignore .gitignore: @@ -98,7 +132,7 @@ By default, Rstring automatically excludes .gitignore patterns. To ignore .gitig rstring --no-gitignore ``` -### Interactive mode: +### Interactive mode Enter interactive mode to continuously preview and select matched files: ```bash @@ -109,7 +143,7 @@ rstring -i 1. **Under the Hood**: Rstring efficiently selects files based on filters by running `rsync --archive --itemize-changes --dry-run --list-only `. This means you can use Rsync's powerful include/exclude patterns to customize file selection. -2. **Preset System**: The default configuration file is at `~/.rstring.yaml`. The 'common' preset is used by default and includes sensible exclusions for most projects. +2. **Default Behavior**: When run without specific patterns, rstring includes all files and directories, filtered by your project's `.gitignore` file. 3. **Output Format**: ``` @@ -128,20 +162,45 @@ rstring -i ## Pro Tips -1. **Explore the default preset**: Check `~/.rstring.yaml` to see how the 'common' preset works. +1. **Start simple**: `rstring` with no arguments gives you everything in your project (filtered by .gitignore). 2. **Refer to Rsync documentation**: Rstring uses Rsync for file selection. Refer to the [Filter Rules](https://linux.die.net/man/1/rsync) section of the rsync man page to understand how include/exclude patterns work. -3. **Customize for your project**: Create a project-specific preset for quick context gathering. +3. **Create project-specific aliases**: Set up shell aliases for your common file selection patterns. 4. **Use with AI tools**: Rstring is great for preparing code context for AI programming assistants. 5. **Large projects may produce substantial output**: Use `--preview-length` or specific patterns for better manageability. +## Common Patterns + +Here are some useful rsync patterns for different scenarios: + +```bash +# Python projects +rstring --include='*/' --include='*.py' --exclude='__pycache__/' --exclude='test*' + +# JavaScript/Node.js projects +rstring --include='*/' --include='*.{js,ts,jsx,tsx}' --exclude='node_modules/' --exclude='test*' + +# Web projects (frontend) +rstring --include='*/' --include='*.{js,ts,css,html,vue,svelte}' --exclude='dist/' --exclude='build/' + +# Documentation only +rstring --include='*/' --include='*.{md,rst,txt}' --exclude='*' + +# Configuration files +rstring --include='*/' --include='*.{json,yaml,yml,toml,ini}' --exclude='*' + +# Source code only (exclude tests, docs, config) +rstring --include='src/' --include='lib/' --exclude='*' +``` + ## Support and Contributing -- Issues and feature requests: [GitHub Issues](https://github.com/tnunamak/rstring/issues) -- Contributions: Pull requests are welcome! +- **Issues and feature requests**: [GitHub Issues](https://github.com/tnunamak/rstring/issues) +- **Contributing**: See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines +- **Pull requests welcome!** ## License diff --git a/requirements-dev.txt b/requirements-dev.txt index fe93bd5..c7285ba 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1 +1,2 @@ pytest==8.3.2 +hypothesis==6.108.5 diff --git a/rstring/cli.py b/rstring/cli.py index b1198b3..c58659a 100644 --- a/rstring/cli.py +++ b/rstring/cli.py @@ -1,28 +1,84 @@ import argparse import logging import os +import sys from .utils import ( - load_presets, save_presets, check_rsync, run_rsync, validate_rsync_args, + check_rsync, run_rsync, validate_rsync_args, gather_code, interactive_mode, get_tree_string, copy_to_clipboard, - get_default_preset, set_default_preset, parse_gitignore + parse_gitignore ) +from .git import filter_ignored_files + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +def parse_target_directory(args): + """Parse target directory from arguments with -C flag and positional support.""" + # Look for -C/--directory flag first (use the last one if multiple) + target_dir = None + remaining_args = [] + i = 0 + + while i < len(args): + arg = args[i] + if arg in ['-C', '--directory']: + if i + 1 < len(args): + target_dir = args[i + 1] + i += 2 # Skip both flag and value + else: + raise ValueError("-C/--directory requires a directory argument") + elif arg.startswith('--directory='): + target_dir = arg.split('=', 1)[1] + i += 1 + else: + remaining_args.append(arg) + i += 1 + + # If no -C flag, check for positional directory argument (only the first non-flag arg) + if target_dir is None and remaining_args: + first_arg = remaining_args[0] + if not first_arg.startswith('-') and os.path.isdir(first_arg): + target_dir = first_arg + remaining_args = remaining_args[1:] + + # Default to current directory + if target_dir is None: + target_dir = '.' + + return os.path.abspath(target_dir), remaining_args + + +def get_default_patterns(): + """Conservative default: include everything, let gitignore filter.""" + return ['--include=*/'] + + def main(): if not check_rsync(): - print("Error: rsync is not installed on this system. Please install rsync and try again.") + print("Error: rsync is not installed on this system. Please install rsync and try again.", file=sys.stderr) return - parser = argparse.ArgumentParser(description="Stringify code with rsync and manage presets.", allow_abbrev=False) - parser.add_argument("-p", "--preset", help="Use a saved preset") - parser.add_argument("-sp", "--save-preset", type=str, metavar="NAME", help="Save the command as a preset") - parser.add_argument("-lp", "--list-presets", action="store_true", help="List all saved presets") - parser.add_argument("-dp", "--delete-preset", help="Delete a saved preset") - parser.add_argument("-sdp", "--set-default-preset", help="Set the default preset") + parser = argparse.ArgumentParser( + description="Stringify code with rsync filtering.", + epilog=""" +Examples: + rstring # All files (gitignore filtered) + rstring --include='*.py' # Only Python files + rstring -C /path/to/project # Different directory + rstring --include='*/' --include='*.js' --exclude='test*' # Complex patterns + +For frequently used patterns, create shell aliases: + alias rstring-py="rstring --include='*/' --include='*.py' --exclude='test*'" + alias rstring-web="rstring --include='*/' --include='*.{js,css,html}'" + """, + formatter_class=argparse.RawDescriptionHelpFormatter, + allow_abbrev=False + ) + + parser.add_argument("-C", "--directory", help="Change to directory before processing") parser.add_argument("-i", "--interactive", action="store_true", help="Enter interactive mode") parser.add_argument("-nc", "--no-clipboard", action="store_true", help="Don't copy output to clipboard") parser.add_argument("-pl", "--preview-length", type=int, metavar="N", @@ -35,76 +91,80 @@ def main(): args, unknown_args = parser.parse_known_args() - presets = load_presets() - - if args.list_presets: - print("Saved presets:") - for name, preset in presets.items(): - print(f" {'*' if preset.get('is_default', False) else ' '} {name}: {' '.join(preset['args'])}") - return - - if args.delete_preset: - if args.delete_preset in presets: - del presets[args.delete_preset] - save_presets(presets) - print(f"Preset '{args.delete_preset}' deleted.") + # Parse target directory from -C flag or positional args + try: + if args.directory: + target_dir = os.path.abspath(args.directory) + rsync_args_base = unknown_args else: - print(f"Preset '{args.delete_preset}' not found.") + target_dir, rsync_args_base = parse_target_directory(unknown_args) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) return - if args.set_default_preset: - set_default_preset(presets, args.set_default_preset) + # Validate target directory exists + if not os.path.isdir(target_dir): + print(f"Error: Directory '{target_dir}' does not exist.", file=sys.stderr) return - preset_name = args.preset or get_default_preset(presets) if not unknown_args else None - if preset_name: - preset = presets.get(preset_name) - if preset: - rsync_args = preset['args'] - else: - print(f"Error: Preset '{preset_name}' not found.") - return + # Use provided patterns or conservative default + if rsync_args_base: + rsync_args = rsync_args_base else: - rsync_args = [] - - rsync_args.extend(unknown_args) - - if args.save_preset: - name = args.save_preset - presets[name] = {'is_default': False, 'args': rsync_args} - save_presets(presets) - print(f"Preset '{name}' saved.") - return + rsync_args = get_default_patterns() + # Handle gitignore in target directory if args.use_gitignore: - gitignore_path = os.path.join(os.getcwd(), '.gitignore') + gitignore_path = os.path.join(target_dir, '.gitignore') if os.path.exists(gitignore_path): gitignore_patterns = parse_gitignore(gitignore_path) rsync_args = gitignore_patterns + rsync_args else: - print("Warning: No .gitignore file found. Use --no-gitignore to ignore .gitignore patterns") + print(f"Warning: No .gitignore file found in {target_dir}. Use --no-gitignore to ignore .gitignore patterns", file=sys.stderr) + # Add default source if none specified if not any(arg for arg in rsync_args if not arg.startswith('--')): rsync_args.append('.') - if not validate_rsync_args(rsync_args): - print("Error: Invalid rsync arguments. Please check and try again.") - return + # Change to target directory for rsync execution + original_cwd = os.getcwd() + try: + os.chdir(target_dir) + + if not validate_rsync_args(rsync_args): + print("Error: Invalid rsync arguments. Please check and try again.", file=sys.stderr) + return - if args.interactive: - rsync_args = interactive_mode(rsync_args, args.include_dirs) + if args.interactive: + rsync_args = interactive_mode(rsync_args, args.include_dirs) - file_list = run_rsync(rsync_args) - result = gather_code(file_list, args.preview_length, args.include_dirs) + file_list = run_rsync(rsync_args) + + # Apply git filtering if in a git repository + try: + file_list = filter_ignored_files(target_dir, file_list) + except Exception as e: + logger.warning(f"Git filtering failed: {e}") + + result = gather_code(file_list, args.preview_length, args.include_dirs) + + tree = get_tree_string(file_list, include_dirs=args.include_dirs, use_color=False) + num_files = len([f for f in file_list if not os.path.isdir(f)]) + + finally: + os.chdir(original_cwd) - tree = get_tree_string(file_list, include_dirs=args.include_dirs, use_color=False) - num_files = len([f for f in file_list if not os.path.isdir(f)]) if args.summary: from datetime import datetime + lines = len(result.splitlines()) + chars = len(result) + tokens = chars // 4 # Rough estimate: 1 token ≈ 4 characters result_with_summary = ["### COLLECTION SUMMARY ###", "", - "The following files have been collected using the Rstring command.", + "The following files have been collected using the rstring command.", "Binary files are truncated to the first 32 bytes.", "", f"Files: {num_files}", - f"Lines: {len(result.splitlines())}", + f"Lines: {lines}", + f"Characters: {chars:,}", + f"Tokens (est.): ~{tokens:,}", f"Collected at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", "", tree, "", "### FILE CONTENTS ###", result] @@ -119,24 +179,14 @@ def main(): copy_to_clipboard(result) if not args.no_clipboard: - action = f"Collected {len(result.splitlines())} lines from {num_files}" if args.no_clipboard else f"Copied {len(result.splitlines())} lines from {num_files} files to clipboard" - if preset_name: - preset = presets.get(preset_name) if preset_name else None - if ' '.join(rsync_args) != ' '.join(preset['args']): - if 'gitignore_patterns' in locals() and ' '.join(gitignore_patterns + preset['args']) != ' '.join( - rsync_args): - print(f"{action} using preset '{preset_name}' with modified rsync options: {' '.join(rsync_args)}") - else: - print(f"{action} using preset '{preset_name}' modified by .gitignore") - else: - print(f"{action} using preset '{preset_name}'") - else: - if 'gitignore_patterns' in locals(): - print( - f"{action} using custom rsync options modified by .gitignore: {' '.join(rsync_args).replace(' '.join(gitignore_patterns), '')}") - else: - print(f"{action} using custom rsync options: {' '.join(rsync_args)}:") + lines = len(result.splitlines()) + chars = len(result) + tokens = chars // 4 # Rough estimate: 1 token ≈ 4 characters + action = f"Collected {lines} lines ({chars:,} chars, ~{tokens:,} tokens) from {num_files}" if args.no_clipboard else f"Copied {lines} lines ({chars:,} chars, ~{tokens:,} tokens) from {num_files} files to clipboard" + target_info = f" from {target_dir}" if target_dir != original_cwd else "" + if 'RSTRING_TESTING' not in os.environ: + print(f"{action}{target_info}") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/rstring/default_presets.yaml b/rstring/default_presets.yaml deleted file mode 100644 index 69f408e..0000000 --- a/rstring/default_presets.yaml +++ /dev/null @@ -1,226 +0,0 @@ -everything: - is_default: false - args: - - . - -common: - is_default: true - args: - # Common exclusions - - --exclude=.* - - --exclude=.*/ - - --exclude=*.log - - --exclude=*.bak - - --exclude=*.tmp - - --exclude=*.temp - - --exclude=*.swp - - --exclude=*.swo - - --exclude=*~ - - --exclude=Thumbs.db - - --exclude=.DS_Store - - # Version control - - --exclude=.git - - --exclude=.svn - - --exclude=.hg - - # IDE and editor files - - --exclude=.vscode - - --exclude=.idea - - --exclude=*.sublime-* - - # Build outputs and dependencies - - --exclude=venv - - --exclude=env - - --exclude=.venv - - --exclude=__pycache__ - - --exclude=*.pyc - - --exclude=*.pyo - - --exclude=*.pyd - - --exclude=*.egg-info - - --exclude=.pytest_cache - - --exclude=.mypy_cache - - --exclude=.coverage - - --exclude=htmlcov - - --exclude=node_modules - - --exclude=bower_components - - --exclude=dist - - --exclude=build - - --exclude=.next - - --exclude=.nuxt - - --exclude=*.min.js - - --exclude=*.min.css - - --exclude=vendor/ - - --exclude=*.class - - --exclude=*.jar - - --exclude=*.war - - --exclude=*.ear - - --exclude=target/ - - --exclude=*.o - - --exclude=*.a - - --exclude=*.so - - --exclude=*.dll - - --exclude=*.exe - - --exclude=*.out - - --exclude=*.app - - --exclude=*.dylib - - --exclude=*.bin - - --exclude=*.dSYM - - --exclude=*.elf - - # Lock files - - --exclude=package-lock.json - - --exclude=yarn.lock - - --exclude=Gemfile.lock - - --exclude=Pipfile.lock - - --exclude=Cargo.lock - - # Potential secret files - - --exclude=*.env - - --exclude=.env* - - --exclude=*.pem - - --exclude=*.key - - --exclude=*_rsa - - --exclude=*_dsa - - --exclude=*.crt - - --exclude=*.cer - - # Documentation and media files - - --exclude=*.pdf - - --exclude=*.doc - - --exclude=*.docx - - --exclude=*.ppt - - --exclude=*.pptx - - --exclude=*.xls - - --exclude=*.xlsx - - --exclude=*.jpg - - --exclude=*.jpeg - - --exclude=*.png - - --exclude=*.gif - - --exclude=*.bmp - - --exclude=*.svg - - --exclude=*.ico - - --exclude=*.mp3 - - --exclude=*.mp4 - - --exclude=*.avi - - --exclude=*.mov - - # Include all directory paths - - --include=*/ - - # Include common source code files - - --include=*.py - - --include=*.ipynb - - --include=*.js - - --include=*.ts - - --include=*.jsx - - --include=*.tsx - - --include=*.html - - --include=*.htm - - --include=*.css - - --include=*.scss - - --include=*.sass - - --include=*.rb - - --include=*.erb - - --include=*.java - - --include=*.kt - - --include=*.groovy - - --include=*.c - - --include=*.cpp - - --include=*.cc - - --include=*.cxx - - --include=*.h - - --include=*.hpp - - --include=*.cs - - --include=*.fs - - --include=*.vb - - --include=*.go - - --include=*.rs - - --include=*.php - - --include=*.scala - - --include=*.swift - - --include=*.m - - --include=*.mm - - --include=*.sh - - --include=*.bash - - --include=*.zsh - - --include=*.sql - - # Include common configuration and data files - - --include=*.ini - - --include=*.cfg - - --include=*.conf - - --include=*.toml - - --include=*.yaml - - --include=*.yml - - --include=*.json - - # Include specific important configuration files - - --include=.gitignore - - --include=.dockerignore - - --include=requirements.txt - - --include=Pipfile - - --include=package.json - - --include=tsconfig.json - - --include=Gemfile - - --include=pom.xml - - --include=build.gradle - - --include=settings.gradle - - --include=Cargo.toml - - --include=.editorconfig - - # Include documentation files - - --include=*.md - - --include=*.rst - - --include=*.txt - - # Include other important files - - --include=Dockerfile - - --include=*.dockerfile - - --include=Makefile - - --include=*.mk - - --include=*.gradle - - --include=*.plist - - # Exclude everything else - - --exclude=* - - # Root directory - - . - -# Add more presets as needed, for example: - -docs-only: - is_default: false - args: - - # Include all directories for traversal - - --include=*/ - - # Include only documentation files - - --include=*.md - - --include=*.rst - - --include=*.txt - - --include=*.adoc - - --include=*.docx - - --include=*.pdf - - # Include specific documentation files - - --include=README* - - --include=CONTRIBUTING* - - --include=CHANGELOG* - - --include=LICENSE* - - # Include documentation directories - - --include=docs/*** - - --include=documentation/*** - - --include=wiki/*** - - # Prune other directories to speed up the process - - --prune-empty-dirs - - # Exclude everything by default - - --exclude=* - - # Root directory - - . diff --git a/rstring/git.py b/rstring/git.py new file mode 100644 index 0000000..25588d7 --- /dev/null +++ b/rstring/git.py @@ -0,0 +1,46 @@ +import logging +import subprocess + +logger = logging.getLogger(__name__) + + +def is_git_command_available(): + try: + subprocess.run(['git', '--version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def is_ignored_by_git(target_dir, file_path): + try: + logger.debug(f"Checking if {file_path} is ignored by git in {target_dir}") + result = subprocess.run( + ['git', 'check-ignore', '-q', file_path], + cwd=target_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + logger.debug(f"Git check-ignore return code for {file_path}: {result.returncode}") + return result.returncode == 0 + except subprocess.CalledProcessError: + logger.debug(f"CalledProcessError for {file_path}") + return False + except Exception as e: + logger.error(f"Error checking if {file_path} is ignored by git: {str(e)}") + return False + + +def filter_ignored_files(target_dir, file_list): + if not is_git_command_available(): + logger.warning("Git command is not available.") + return file_list # Return the original list if git is not available + + logger.debug(f"Filtering ignored files in {target_dir}") + logger.debug(f"Original file list: {file_list}") + filtered_list = [ + file_path for file_path in file_list + if not is_ignored_by_git(target_dir, file_path) + ] + logger.debug(f"Filtered file list: {filtered_list}") + return filtered_list \ No newline at end of file diff --git a/rstring/utils.py b/rstring/utils.py index 31b9116..82e9d09 100644 --- a/rstring/utils.py +++ b/rstring/utils.py @@ -4,65 +4,13 @@ import platform import shlex import subprocess - -import yaml +import sys logger = logging.getLogger(__name__) -PRESETS_FILE = os.path.expanduser("~/.rstring.yaml") -DEFAULT_PRESETS_FILE = os.path.join(os.path.dirname(__file__), 'default_presets.yaml') - from .tree import get_tree_string -def load_presets(): - if os.path.exists(PRESETS_FILE): - try: - with open(PRESETS_FILE, 'r') as f: - return yaml.safe_load(f) - except yaml.YAMLError as e: - logger.error(f"Error parsing {PRESETS_FILE}: {e}") - print(f"Error parsing {PRESETS_FILE}. Using empty presets.") - except Exception as e: - logger.error(f"Error reading {PRESETS_FILE}: {e}") - print(f"Error reading {PRESETS_FILE}. Using empty presets.") - else: - try: - with open(DEFAULT_PRESETS_FILE, 'r') as df: - content = df.read() - with open(PRESETS_FILE, 'w') as f: - f.write(content) - return yaml.safe_load(content) or {} - except Exception as e: - logger.error(f"Error reading or writing preset files: {e}") - print(f"Error with preset files. Using empty presets.") - return {} - - -def save_presets(presets): - with open(PRESETS_FILE, 'w') as f: - yaml.dump(presets, f) - - -def get_default_preset(presets): - for name, preset in presets.items(): - if preset.get('is_default', False): - return name - return None - - -def set_default_preset(presets, preset_name): - if preset_name not in presets: - print(f"Error: Preset '{preset_name}' not found") - return - - for name, preset in presets.items(): - preset['is_default'] = (name == preset_name) - - save_presets(presets) - print(f"Default preset set to '{preset_name}'") - - def parse_gitignore(gitignore_path): if not os.path.exists(gitignore_path): return [] @@ -163,18 +111,18 @@ def gather_code(file_list, preview_length=None, include_dirs=False): return result[:-2] -def interactive_mode(initial_args, include_dirs=False): +def interactive_mode(initial_args, include_dirs=False, stdout=sys.stdout): args = initial_args.copy() while True: - print(args) + print(args, file=stdout) if not validate_rsync_args(args): - print("Error: Invalid rsync arguments. Please try again.") + print("Error: Invalid rsync arguments. Please try again.", file=sys.stderr) continue file_list = run_rsync(args) - print("\nCurrent file list:") - print(get_tree_string(file_list, include_dirs=include_dirs)) - print(f"\nCurrent rsync arguments: {' '.join(args)}") + print("\nCurrent file list:", file=stdout) + print(get_tree_string(file_list, include_dirs=include_dirs), file=stdout) + print(f"\nCurrent rsync arguments: {' '.join(args)}", file=stdout) action = input("\nEnter an action (a)dd/(r)emove/(e)dit/(d)one: ").lower() if action in ['done', 'd']: @@ -193,9 +141,9 @@ def interactive_mode(initial_args, include_dirs=False): if validate_rsync_args(new_args): args = new_args else: - print("Error: Invalid rsync arguments. Please try again.") + print("Error: Invalid rsync arguments. Please try again.", file=sys.stderr) else: - print("Invalid action. Please enter 'a', 'r', 'e', or 'd'.") + print("Invalid action. Please enter 'a', 'r', 'e', or 'd'.", file=stdout) return args @@ -203,14 +151,18 @@ def interactive_mode(initial_args, include_dirs=False): def copy_to_clipboard(text): system = platform.system() try: - if system == 'Darwin': # macOS - subprocess.run(['pbcopy'], input=text.encode('utf-8'), check=True) - elif system == 'Windows': - subprocess.run(['clip'], input=text.encode('utf-8'), check=True) - elif system == 'Linux': - try: - subprocess.run(['xclip', '-selection', 'clipboard'], input=text.encode('utf-8'), check=True) - except FileNotFoundError: - subprocess.run(['xsel', '--clipboard', '--input'], input=text.encode('utf-8'), check=True) - except Exception as e: + if system == "Darwin": # macOS + subprocess.run(["pbcopy"], input=text, text=True, check=True) + elif system == "Linux": + subprocess.run(["xclip", "-selection", "clipboard"], input=text, text=True, check=True) + elif system == "Windows": + subprocess.run(["clip"], input=text, text=True, check=True) + else: + print(f"Unsupported platform: {system}") + except subprocess.CalledProcessError as e: print(f"Failed to copy to clipboard: {e}") + except FileNotFoundError: + if system == "Linux": + print("xclip not found. Please install xclip to enable clipboard functionality.") + else: + print("Clipboard functionality not available.") diff --git a/setup.py b/setup.py index 26e3810..1e47bd4 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ version="0.3.0", author="Tim Nunamaker", author_email="tim.nunamaker@gmail.com", - description="A tool to stringify code using rsync and manage presets", + description="A tool to stringify code using rsync filtering", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/tnunamak/rstring", @@ -36,13 +36,12 @@ }, install_requires=[ "colorama>=0.4.6", - "pyyaml>=6.0.1", ], extras_require={ - 'dev': ['pytest>=8.3.2'], - }, - package_data={ - "rstring": ["default_presets.yaml"], + 'dev': [ + 'pytest>=8.3.2', + 'hypothesis>=6.108.5', + ] }, include_package_data=True, ) diff --git a/tests/test_rstring.py b/tests/test_rstring.py index ef6d6a7..6febc0b 100644 --- a/tests/test_rstring.py +++ b/tests/test_rstring.py @@ -1,36 +1,20 @@ import subprocess from unittest.mock import patch, MagicMock, mock_open - +import tempfile import pytest -import yaml - -from rstring import utils, cli - - -@pytest.fixture -def temp_config(tmp_path): - config_file = tmp_path / '.rstring.yaml' - config_file.touch() - with patch('rstring.utils.PRESETS_FILE', str(config_file)): - yield config_file - if config_file.exists(): - config_file.unlink() +import os +import sys +# Add the parent directory to the path so we can import rstring +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -def test_load_presets(temp_config): - test_preset = {'test_preset': {'args': ['--include=*.py']}} - temp_config.write_text(yaml.dump(test_preset)) - - presets = utils.load_presets() - assert presets == test_preset - - -def test_save_presets(temp_config): - test_preset = {'test_preset': {'args': ['--include=*.py']}} - utils.save_presets(test_preset) - - saved_preset = yaml.safe_load(temp_config.read_text()) - assert saved_preset == test_preset +from rstring import utils, cli +from rstring.utils import ( + check_rsync, run_rsync, validate_rsync_args, + gather_code, interactive_mode, get_tree_string, copy_to_clipboard, + parse_gitignore, is_binary +) +from rstring.cli import parse_target_directory, main def test_check_rsync(): @@ -39,28 +23,6 @@ def test_check_rsync(): assert utils.check_rsync() == True -def test_load_presets_invalid_yaml(temp_config): - temp_config.write_text('invalid: yaml: :]') - - presets = utils.load_presets() - assert presets == {} - - -def test_load_presets_file_not_found(temp_config): - temp_config.unlink() - mock_default_content = yaml.dump({'default_preset': {'args': ['--include=*.py']}}) - - with patch('rstring.utils.PRESETS_FILE', 'nonexistent_file'): - with patch('rstring.utils.DEFAULT_PRESETS_FILE', 'default_presets.yaml'): - with patch('builtins.open', mock_open(read_data=mock_default_content)) as mock_file: - presets = utils.load_presets() - - assert presets == {'default_preset': {'args': ['--include=*.py']}} - mock_file.assert_any_call('default_presets.yaml', 'r') - mock_file.assert_any_call('nonexistent_file', 'w') - mock_file().write.assert_called_once_with(mock_default_content) - - def test_run_rsync(): mock_output = ( "drwxr-xr-x 4,096 2023/04/01 12:00:00 .\n" @@ -122,8 +84,9 @@ def test_interactive_mode(): mock_input.side_effect = ['a', '*.txt', 'd'] with patch('rstring.utils.validate_rsync_args', return_value=True): with patch('rstring.utils.run_rsync', return_value=['file1.txt']): - result = utils.interactive_mode(['--include=*.py']) - assert result == ['--include=*.py', '--include', '*.txt'] + with open(os.devnull, 'w') as devnull: + result = utils.interactive_mode(['--include=*.py'], stdout=devnull) + assert result == ['--include=*.py', '--include', '*.txt'] def test_print_tree(): @@ -169,17 +132,137 @@ def test_copy_to_clipboard(system, command): assert mock_run.call_args[0][0][0] == command -def test_main(temp_config): - test_args = ['rstring', '--preset', 'test_preset'] - test_preset = {'test_preset': {'args': ['--include=*.py']}} - temp_config.write_text(yaml.dump(test_preset)) +def test_parse_target_directory(): + """Test the parse_target_directory function.""" + # Test -C flag + target_dir, remaining = parse_target_directory(['-C', '/tmp', '--include=*.py']) + assert target_dir == '/tmp' + assert remaining == ['--include=*.py'] + + # Test --directory flag + target_dir, remaining = parse_target_directory(['--directory', '/home', '--include=*.js']) + assert target_dir == '/home' + assert remaining == ['--include=*.js'] + + # Test positional argument + with tempfile.TemporaryDirectory() as temp_dir: + target_dir, remaining = parse_target_directory([temp_dir, '--include=*.py']) + assert target_dir == temp_dir + assert remaining == ['--include=*.py'] + # Test default to current directory + target_dir, remaining = parse_target_directory(['--include=*.py']) + assert target_dir == os.path.abspath('.') + assert remaining == ['--include=*.py'] + + +def test_main_with_default_patterns(): + """Test main function with default patterns (no user args).""" mock_gathered_code = 'print("Hello")\n' * 26 - with patch('sys.argv', test_args): - with patch('rstring.utils.check_rsync', return_value=True): + with patch('rstring.cli.check_rsync', return_value=True): + with patch('rstring.cli.run_rsync', return_value=['test.py']): with patch('rstring.cli.gather_code', return_value=mock_gathered_code): with patch('rstring.cli.copy_to_clipboard') as mock_copy: + with patch('rstring.cli.get_tree_string', return_value='test.py'): + with patch('rstring.cli.filter_ignored_files', return_value=['test.py']): + with patch.dict(os.environ, {'RSTRING_TESTING': 'True'}): + with patch('sys.argv', ['rstring']): + cli.main() + mock_copy.assert_called_once_with(mock_gathered_code) + + +@patch('rstring.cli.filter_ignored_files') +@patch('rstring.cli.run_rsync') +@patch('rstring.cli.gather_code') +@patch('rstring.cli.copy_to_clipboard') +@patch('rstring.cli.get_tree_string') +@patch('rstring.cli.check_rsync') +def test_main_with_target_directory(mock_check_rsync, mock_get_tree_string, + mock_copy_to_clipboard, mock_gather_code, mock_run_rsync, mock_filter_ignored): + """Test main function with target directory functionality.""" + mock_check_rsync.return_value = True + mock_run_rsync.return_value = ['test.py'] + mock_gather_code.return_value = 'test content' + mock_get_tree_string.return_value = 'tree' + mock_filter_ignored.return_value = ['test.py'] + + with tempfile.TemporaryDirectory() as temp_dir: + # Create a test file + test_file = os.path.join(temp_dir, 'test.py') + with open(test_file, 'w') as f: + f.write('print("test")') + + with patch('sys.argv', ['rstring', '-C', temp_dir, '--include=*.py']): + with patch.dict(os.environ, {'RSTRING_TESTING': 'True'}): + with patch('os.chdir') as mock_chdir: cli.main() - mock_copy.assert_called_once() - mock_copy.assert_called_with(mock_gathered_code) + + # Should change to target directory and back + assert mock_chdir.call_count == 2 + mock_copy_to_clipboard.assert_called_once_with('test content') + + +def test_main_with_nonexistent_directory(): + """Test main function with non-existent directory.""" + with patch('rstring.cli.check_rsync', return_value=True): + with patch('sys.argv', ['rstring', '-C', '/nonexistent']): + with patch('builtins.print') as mock_print: + cli.main() + mock_print.assert_called_with("Error: Directory '/nonexistent' does not exist.", file=sys.stderr) + + +def test_main_rsync_not_found(): + """Test main function when rsync is not found.""" + with patch('rstring.cli.check_rsync', return_value=False): + with patch('builtins.print') as mock_print: + cli.main() + mock_print.assert_called_with("Error: rsync is not installed on this system. Please install rsync and try again.", file=sys.stderr) + + +def test_main_with_missing_directory_arg(): + """Test main function with -C flag but no directory.""" + with patch('rstring.cli.check_rsync', return_value=True): + with patch('sys.argv', ['rstring', '-C']): + with pytest.raises(SystemExit): + cli.main() + + +def test_parse_gitignore(): + """Test gitignore parsing functionality.""" + gitignore_content = """ +# Comment +__pycache__/ +*.pyc +/build +node_modules/ +.env +""" + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.gitignore') as f: + f.write(gitignore_content) + f.flush() + + patterns = parse_gitignore(f.name) + + expected_patterns = [ + '--exclude=.git', + '--exclude=__pycache__/*', + '--exclude=*.pyc', + '--exclude=build', + '--exclude=node_modules/*', + '--exclude=.env' + ] + + assert patterns == expected_patterns + + # Clean up + os.unlink(f.name) + + +def test_get_default_patterns(): + """Test the get_default_patterns function.""" + from rstring.cli import get_default_patterns + + patterns = get_default_patterns() + assert patterns == ['--include=*/']