From 8001b7a4f4b49ccc7b091ee70476362ae0a6ccce Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 21:46:53 -0500 Subject: [PATCH 01/12] feat: add clean target directory support with -C flag and positional args --- ANALYSIS.md | 146 ++++++++++++ DETERMINE_TARGET_DIRECTORY_ANALYSIS.md | 272 ++++++++++++++++++++++ POWER_ANALYSIS.md | 273 ++++++++++++++++++++++ defunct/stringify-old.py | 49 ++++ defunct/stringify.py.before | 102 +++++++++ defunct/stringify.py.lastbeforersync | 128 +++++++++++ defunct/stringify.py.singlefile | 279 +++++++++++++++++++++++ defunct/test_stringify.py.before | 121 ++++++++++ defunct/test_stringify.py.disabled | 154 +++++++++++++ defunct/test_stringify_rsync.py.disabled | 72 ++++++ nothin/emptyfile.txt | 0 nothin/nonemptyfile.txt | 1 + rstring/cli.py | 110 +++++++-- system_limits_audit.md | 90 ++++++++ tests/empty/anything | 0 tests/test_rstring.py | 94 ++++++++ 16 files changed, 1869 insertions(+), 22 deletions(-) create mode 100644 ANALYSIS.md create mode 100644 DETERMINE_TARGET_DIRECTORY_ANALYSIS.md create mode 100644 POWER_ANALYSIS.md create mode 100755 defunct/stringify-old.py create mode 100755 defunct/stringify.py.before create mode 100755 defunct/stringify.py.lastbeforersync create mode 100755 defunct/stringify.py.singlefile create mode 100755 defunct/test_stringify.py.before create mode 100644 defunct/test_stringify.py.disabled create mode 100644 defunct/test_stringify_rsync.py.disabled create mode 100644 nothin/emptyfile.txt create mode 100644 nothin/nonemptyfile.txt create mode 100644 system_limits_audit.md create mode 100644 tests/empty/anything diff --git a/ANALYSIS.md b/ANALYSIS.md new file mode 100644 index 0000000..7bddb51 --- /dev/null +++ b/ANALYSIS.md @@ -0,0 +1,146 @@ +# Rstring: Strategic Engineering Analysis + +## Executive Summary + +Rstring is a developer tool that leverages rsync's powerful file filtering capabilities to efficiently gather and stringify code from projects, primarily for feeding to AI programming assistants. The core insight—using rsync as the file selection engine—is architecturally brilliant, providing enterprise-grade filtering with minimal code complexity. + +## 1. What It Is + +Rstring is a command-line utility that: +- Uses rsync's include/exclude patterns to select files from codebases +- Concatenates selected files into a single string with clear delimiters +- Automatically copies output to clipboard for AI assistant consumption +- Provides preset management for common file selection patterns +- Integrates with git to respect .gitignore patterns +- Offers interactive mode for iterative file selection refinement + +The tool addresses a specific pain point: efficiently preparing code context for AI programming assistants without manually copying files or writing complex filtering logic. + +## 2. Architectural Genius and Limitations + +### Genius +1. **Rsync as the Core Engine**: Using rsync for file selection is architecturally brilliant. Rsync's filter system is mature, battle-tested, and incredibly powerful. This single decision provides: + - Complex pattern matching (wildcards, directory traversal, exclusions) + - High performance on large codebases + - Familiar syntax for Unix users + - Zero need to reinvent file filtering logic + +2. **Preset System**: The YAML-based preset system with defaults is well-designed: + - Sensible defaults that work for most projects + - Easy customization and sharing + - Default preset concept reduces cognitive load + +3. **Composability**: The tool composes well with other Unix tools and workflows, following Unix philosophy. + +### Limitations +1. **Rsync Dependency**: Requires rsync installation, which may not be available on all systems (particularly Windows without WSL). + +2. **Complex Error Handling**: Rsync errors can be cryptic for non-technical users. + +3. **Limited Cross-Platform Support**: While functional, the clipboard integration and rsync dependency make it less seamless on Windows. + +## 3. UX Analysis + +### Power +- **Immediate Utility**: Solves a real problem developers face daily +- **Flexible Filtering**: Rsync patterns provide enormous flexibility +- **Clipboard Integration**: Seamless workflow integration +- **Tree Visualization**: Helps users understand what files are selected +- **Interactive Mode**: Allows iterative refinement + +### Flaws +- **Learning Curve**: Rsync pattern syntax is not intuitive for casual users +- **Error Messages**: Cryptic rsync errors don't guide users effectively +- **Discovery**: Hard to discover optimal patterns without rsync knowledge +- **Feedback Loop**: Limited preview capabilities before full execution + +## 4. Analysis of Unshipped Changes (other-targets branch) + +### The Good Changes + +1. **Target Directory Support**: The `determine_target_directory()` function attempts to support specifying different source directories, which is valuable for working with multiple projects. + +2. **Improved Tree Visualization**: The tree.py refactor improves path handling and makes the tree generation more robust. + +3. **Better Path Handling**: More careful path normalization and absolute path handling. + +4. **Enhanced Testing**: Addition of property-based testing with Hypothesis shows good engineering practices. + +5. **Git Integration**: The git.py module for filtering ignored files is a sensible addition. + +### The Problematic Changes + +1. **Incomplete Implementation**: The `determine_target_directory()` function is clearly unfinished: + - Contains debug print statements + - Complex logic that's hard to follow + - Unclear error handling + - The "TODO: fix this hack" comment in `gather_code()` indicates rushed implementation + +2. **Breaking Changes**: The function signature changes break existing functionality (evident from the TypeError in preset listing). + +3. **Overengineering**: The target directory detection logic is overly complex for what should be a simple feature. + +4. **Inconsistent State**: The branch contains both working improvements and broken functionality. + +## 5. Strategic Recommendations + +### High-Leverage Improvements + +1. **Simplify Target Directory Support**: + - Instead of complex rsync output parsing, simply accept a `--directory` flag + - Use `os.chdir()` or pass working directory to subprocess calls + - Much simpler and more predictable + +2. **Improve Error Handling and UX**: + - Wrap rsync errors with user-friendly messages + - Add `--dry-run` mode to preview file selection + - Better validation of rsync patterns before execution + +3. **Enhanced Discovery**: + - Add `--suggest` mode that analyzes project structure and suggests appropriate patterns + - Include common preset examples in help text + - Better documentation of rsync pattern syntax + +4. **Performance Optimizations**: + - Cache rsync results for interactive mode + - Add progress indicators for large codebases + - Implement file size limits with warnings + +### Architectural Decisions + +1. **Keep Rsync Core**: The rsync dependency is the tool's greatest strength. Don't replace it. + +2. **Preset Evolution**: Consider preset versioning and community sharing mechanisms. + +3. **Plugin Architecture**: Consider allowing custom output formatters while keeping the core simple. + +### Development Section Assessment + +The proposed README development section is **not recommended** for a tool of this caliber. High-end OSS tools typically: +- Have contributing guidelines in CONTRIBUTING.md +- Assume developers can figure out basic setup +- Focus documentation on usage, not development setup +- Keep README focused on user value proposition + +The development section adds cognitive overhead without proportional value for the target audience. + +## 6. Competitive Positioning + +Rstring occupies a unique niche: +- **vs. find/grep**: More powerful filtering, better output format +- **vs. IDE plugins**: Language-agnostic, composable with any workflow +- **vs. custom scripts**: Standardized, maintained, preset system + +The tool's strength is its focused scope and architectural leverage of existing tools. + +## 7. Conclusion + +Rstring demonstrates excellent architectural thinking by leveraging rsync's mature filtering capabilities. The core concept is sound and the implementation is largely well-executed. The unshipped changes show both good engineering instincts (testing, git integration) and problematic overengineering (target directory detection). + +The path forward should focus on: +1. Fixing the broken functionality in other-targets +2. Simplifying the target directory feature +3. Improving user experience around error handling and discovery +4. Maintaining the tool's focused scope and architectural simplicity + +The tool has strong potential for adoption in AI-assisted development workflows, provided the UX rough edges are smoothed and the core reliability is maintained. \ No newline at end of file diff --git a/DETERMINE_TARGET_DIRECTORY_ANALYSIS.md b/DETERMINE_TARGET_DIRECTORY_ANALYSIS.md new file mode 100644 index 0000000..1454c42 --- /dev/null +++ b/DETERMINE_TARGET_DIRECTORY_ANALYSIS.md @@ -0,0 +1,272 @@ +# The `determine_target_directory` Approach: A Deep Technical Analysis + +## Executive Summary + +The `determine_target_directory` function represents a classic case of **over-engineering a simple problem into a complex one**. While the implied goal—supporting arbitrary target directories with maximum flexibility—is admirable, the execution path chosen is fundamentally flawed and would not be pursued by a top-tier engineer. This analysis examines why. + +## The Implied Goal: Maximum Power and Flexibility + +The function attempts to solve this problem: **"Given any rsync command with any combination of patterns and target directories, automatically determine the correct working directory and file paths."** + +The implied vision is powerful: +- `rstring --include=*.py /path/to/project` should work seamlessly +- `rstring --include=*.py ../other-project` should work seamlessly +- `rstring --include=*.py .` should work seamlessly +- All without requiring explicit `--directory` flags or manual path specification + +This is a worthy goal that would significantly improve UX. However, the implementation approach is fundamentally problematic. + +## Technical Analysis of the Current Approach + +### The Core Strategy + +The function uses this strategy: +1. Execute rsync twice: once normally, once with `-R` flag +2. Compare outputs to detect if user used relative paths +3. Parse rsync output to extract file paths +4. Use `os.path.commonpath()` to determine target directory +5. Apply complex logic to "trim" the path based on relative vs absolute detection + +### Execution Path Analysis + +#### Path 1: User provides relative path (e.g., `rstring .`) +```python +# rsync -ain --list-only --include=*.py . +# vs +# rsync -ain --list-only --include=*.py . -R +# Outputs are identical, so user_used_relative = True +``` + +#### Path 2: User provides absolute path (e.g., `rstring /path/to/project`) +```python +# rsync -ain --list-only --include=*.py /path/to/project +# Output: "rstring/.gitignore", "rstring/file.py" +# vs +# rsync -ain --list-only --include=*.py /path/to/project -R +# Output: "home/user/path/to/project/.gitignore", "home/user/path/to/project/file.py" +# Outputs differ, so user_used_relative = False +``` + +#### Path 3: Complex relative paths (e.g., `rstring ../other-project`) +This is where the approach completely breaks down. The logic cannot reliably distinguish between: +- User intention vs rsync's path resolution +- Working directory context vs target directory context +- Relative path semantics in different shell environments + +### Critical Flaws in the Approach + +#### 1. **Rsync Output Parsing Fragility** +```python +def parse_rsync_output_line(line): + return line.split()[4] # Assumes exactly 5 space-separated fields +``` + +Rsync output format is: +``` +-rw-rw-r-- 7,044 2025/05/24 21:35:27 ANALYSIS.md +``` + +This parsing is fragile because: +- File names with spaces break the split logic +- Different rsync versions may have different output formats +- Symlinks, special files, and permissions can alter the format +- Locale settings can affect date/time formatting + +#### 2. **Double Rsync Execution** +Every call to `determine_target_directory` executes rsync **twice**: +- Performance penalty (especially on large codebases) +- Potential for race conditions if filesystem changes between calls +- Doubled error surface area + +#### 3. **Complex State Logic** +```python +if not user_used_relative and original_relative_path != '.': + target_dir_trimmed = target_dir.rsplit('/', 1)[0] +else: + target_dir_trimmed = target_dir +``` + +This logic attempts to handle multiple cases but creates more edge cases: +- What if `rsplit('/', 1)` returns unexpected results? +- What if the path is already at root? +- What about Windows path separators? +- What about symlinks that change path resolution? + +#### 4. **Fundamental Conceptual Flaw** +The approach tries to **reverse-engineer user intent from rsync output**. This is inherently unreliable because: +- Rsync output is the *result* of path resolution, not the *intent* +- Multiple different user inputs can produce identical rsync outputs +- The function conflates "what rsync sees" with "what the user meant" + +## Complexity Explosion Analysis + +### Current Complexity Factors +1. **Rsync behavior matrix**: 4 combinations (relative/absolute × with/without -R) +2. **Path parsing edge cases**: Spaces, special characters, Unicode +3. **Filesystem edge cases**: Symlinks, mount points, permissions +4. **Platform differences**: Windows vs Unix path handling +5. **Error propagation**: Two rsync calls = 2× error scenarios + +### Projected Complexity Growth +If this approach were pursued seriously, it would require handling: +- **Windows path semantics** (drive letters, UNC paths, backslashes) +- **Symlink resolution** (what if target is a symlink?) +- **Mount point boundaries** (what if paths cross filesystems?) +- **Permission edge cases** (what if rsync can list but not read?) +- **Network paths** (NFS, SMB, etc.) +- **Container environments** (Docker volume mounts, etc.) +- **Rsync version differences** (output format variations) +- **Locale variations** (date formats, character encodings) + +Each additional edge case multiplies the testing matrix exponentially. + +## What Top-Tier Engineers Would Consider + +### Option 1: Explicit Directory Flag (Recommended) +```bash +rstring --directory /path/to/project --include=*.py +rstring -C /path/to/project --include=*.py # git-style +``` + +**Pros:** +- Crystal clear semantics +- Zero ambiguity +- Trivial implementation +- Composable with other tools +- Follows established CLI patterns (git -C, make -C, etc.) + +**Cons:** +- Slightly more verbose +- Requires user to specify directory explicitly + +### Option 2: Positional Argument with Clear Semantics +```bash +rstring /path/to/project --include=*.py +``` + +**Implementation:** +```python +def parse_args(): + if len(unknown_args) > 0 and not unknown_args[0].startswith('-'): + target_dir = unknown_args[0] + rsync_args = unknown_args[1:] + preset_args + else: + target_dir = '.' + rsync_args = unknown_args + preset_args +``` + +**Pros:** +- Clean UX +- Simple implementation +- Clear semantics +- No rsync output parsing + +**Cons:** +- Potential ambiguity with rsync patterns +- Requires careful argument parsing + +### Option 3: Working Directory Context (Current Behavior) +```bash +cd /path/to/project && rstring --include=*.py +``` + +**Pros:** +- Follows Unix philosophy +- Zero implementation complexity +- Composable with shell workflows +- No ambiguity + +**Cons:** +- Requires user to change directories +- Less convenient for one-off commands + +### Option 4: Smart Detection with Fallback +```python +def determine_target_directory_simple(args): + # Look for obvious directory arguments + for arg in args: + if not arg.startswith('-') and os.path.isdir(arg): + return os.path.abspath(arg) + return os.getcwd() +``` + +**Pros:** +- Handles 90% of use cases simply +- Clear fallback behavior +- No rsync output parsing +- Fast execution + +**Cons:** +- May not handle all edge cases +- Could misinterpret rsync patterns as directories + +## Engineering Judgment: Would a Serious Engineer Pursue This? + +**Absolutely not.** Here's why: + +### 1. **Complexity-to-Value Ratio** +The current approach has **exponential complexity growth** for **linear value increase**. The 80/20 rule strongly favors simpler solutions. + +### 2. **Reliability Concerns** +The approach introduces multiple failure modes: +- Rsync output parsing failures +- Path resolution edge cases +- Platform-specific behaviors +- Race conditions + +### 3. **Maintenance Burden** +Every rsync version update, platform addition, or edge case discovery requires revisiting this complex logic. + +### 4. **Debugging Nightmare** +When this function fails (and it will), debugging requires: +- Understanding rsync internals +- Reproducing exact filesystem states +- Analyzing complex path resolution logic +- Considering platform-specific behaviors + +### 5. **Violates KISS Principle** +The problem has simple solutions that are more reliable, more maintainable, and more predictable. + +## Recommended Path Forward + +A top-tier engineer would: + +1. **Abandon the current approach entirely** +2. **Implement Option 1 (explicit directory flag)** as the primary interface +3. **Add Option 2 (positional argument)** as syntactic sugar +4. **Keep Option 3 (working directory)** as the default behavior +5. **Document the behavior clearly** with examples + +### Implementation Sketch +```python +def parse_target_directory(args): + """Simple, reliable target directory detection.""" + parser = argparse.ArgumentParser() + parser.add_argument('-C', '--directory', help='Change to directory before processing') + parser.add_argument('target', nargs='?', help='Target directory (optional)') + + parsed, remaining = parser.parse_known_args(args) + + if parsed.directory: + return os.path.abspath(parsed.directory), remaining + elif parsed.target and os.path.isdir(parsed.target): + return os.path.abspath(parsed.target), remaining + else: + return os.getcwd(), args +``` + +This approach is: +- **Simple**: ~10 lines vs ~50 lines +- **Reliable**: No rsync output parsing +- **Fast**: No double execution +- **Maintainable**: Clear logic flow +- **Extensible**: Easy to add new patterns +- **Debuggable**: Obvious failure modes + +## Conclusion + +The `determine_target_directory` approach represents a classic engineering anti-pattern: **solving a simple problem with complex machinery**. While the goal of maximum flexibility is admirable, the implementation path chosen creates more problems than it solves. + +A serious engineer would recognize this as a **complexity trap** and choose one of the simpler, more reliable alternatives. The current approach should be abandoned in favor of explicit, predictable semantics that users can understand and rely on. + +The lesson here is that **engineering judgment** often means choosing the boring, simple solution over the clever, complex one. In this case, the boring solution is objectively superior in every meaningful metric: reliability, maintainability, performance, and user experience. \ No newline at end of file diff --git a/POWER_ANALYSIS.md b/POWER_ANALYSIS.md new file mode 100644 index 0000000..d87b0d0 --- /dev/null +++ b/POWER_ANALYSIS.md @@ -0,0 +1,273 @@ +# Power and Composability Analysis: Simple vs Complex Target Directory Approaches + +## Executive Summary + +**Yes, the simpler approaches maintain 100% of rstring's power and composability while actually *increasing* leverage in many scenarios.** The complex `determine_target_directory` approach doesn't provide additional power—it just obscures the existing power behind unreliable automation. + +## Power Comparison Matrix + +### Core Rsync Power (Unchanged) +Both approaches provide identical access to rsync's full filtering capabilities: + +```bash +# Complex approach +rstring /path/to/project --include=*/ --include=*.py --exclude=test* --exclude=* + +# Simple approach (-C flag) +rstring -C /path/to/project --include=*/ --include=*.py --exclude=test* --exclude=* + +# Simple approach (positional) +rstring /path/to/project --include=*/ --include=*.py --exclude=test* --exclude=* +``` + +**Result: 100% power retention** + +### Preset System Power (Enhanced) +The simple approaches actually *enhance* preset power: + +```bash +# Complex approach (brittle with paths) +rstring /path/to/other-project --preset python # May break due to path parsing + +# Simple approach (reliable) +rstring -C /path/to/other-project --preset python # Always works +rstring /path/to/other-project --preset python # Always works +``` + +**Result: Power increased through reliability** + +### Composability Analysis + +#### Shell Composability (Massively Enhanced) +```bash +# Complex approach: Limited composability due to path parsing ambiguity +rstring $(find . -name "*.project" -type d | head -1) --include=*.py # Risky + +# Simple approach: Perfect composability +rstring -C $(find . -name "*.project" -type d | head -1) --include=*.py # Safe +find . -name "*.project" -type d | xargs -I {} rstring -C {} --preset python # Powerful +``` + +#### Script Integration (Enhanced) +```bash +#!/bin/bash +# Complex approach: Fragile +for project in /path/to/projects/*; do + rstring "$project" --preset common # May fail on edge cases +done + +# Simple approach: Bulletproof +for project in /path/to/projects/*; do + rstring -C "$project" --preset common # Always works +done +``` + +#### CI/CD Integration (Enhanced) +```yaml +# Complex approach: Unreliable in containers +- run: rstring /workspace/src --include=*.py + +# Simple approach: Reliable everywhere +- run: rstring -C /workspace/src --include=*.py +``` + +## Power Features Comparison + +### 1. Multi-Project Workflows + +**Complex Approach:** +```bash +# Fragile - path parsing may fail +rstring ../project-a --include=*.py +rstring /abs/path/to/project-b --include=*.py +``` + +**Simple Approach:** +```bash +# Bulletproof +rstring -C ../project-a --include=*.py +rstring -C /abs/path/to/project-b --include=*.py + +# Even more powerful - batch processing +for proj in ../project-*; do rstring -C "$proj" --preset common; done +``` + +### 2. Complex Path Scenarios + +**Complex Approach:** +```bash +# These may break due to rsync output parsing: +rstring "/path with spaces/project" --include=*.py +rstring "~/projects/my project" --include=*.py +rstring "/mnt/network/share/project" --include=*.py +``` + +**Simple Approach:** +```bash +# These always work: +rstring -C "/path with spaces/project" --include=*.py +rstring -C "~/projects/my project" --include=*.py +rstring -C "/mnt/network/share/project" --include=*.py +``` + +### 3. Advanced Rsync Patterns + +**Both approaches support identical rsync power:** +```bash +# Complex nested includes/excludes +rstring -C /project --include=src/ --include=src/**/*.py --exclude=src/test* --exclude=* + +# Prune empty directories +rstring -C /project --prune-empty-dirs --include=docs/ --include=*.md --exclude=* + +# Complex globbing +rstring -C /project --include=**/test_*.py --exclude=**/*_old.py +``` + +**Result: Identical power, better reliability** + +## Leverage Analysis + +### Current Leverage Points (Maintained) +1. **Rsync's mature filtering system** ✅ Fully maintained +2. **Preset system for reusability** ✅ Enhanced through reliability +3. **Interactive mode for refinement** ✅ Fully maintained +4. **Git integration** ✅ Fully maintained +5. **Tree visualization** ✅ Fully maintained +6. **Clipboard integration** ✅ Fully maintained + +### New Leverage Points (Added) +1. **Predictable behavior** → Enables automation +2. **Shell composability** → Enables complex workflows +3. **Error predictability** → Enables robust scripting +4. **Platform independence** → Enables cross-platform tools + +## Real-World Power Scenarios + +### Scenario 1: Multi-Repository Analysis +```bash +# Complex approach: Fragile +find ~/projects -name ".git" -type d | while read repo; do + project_dir=$(dirname "$repo") + rstring "$project_dir" --preset common # May fail +done + +# Simple approach: Robust +find ~/projects -name ".git" -type d | while read repo; do + project_dir=$(dirname "$repo") + rstring -C "$project_dir" --preset common # Always works +done +``` + +### Scenario 2: Docker/Container Integration +```dockerfile +# Complex approach: Unreliable in containers +RUN rstring /workspace/src --include=*.py > context.txt + +# Simple approach: Reliable everywhere +RUN rstring -C /workspace/src --include=*.py > context.txt +``` + +### Scenario 3: IDE/Editor Integration +```python +# Complex approach: Hard to integrate reliably +def get_project_context(project_path): + # Risk of path parsing failures + result = subprocess.run(['rstring', project_path, '--preset', 'common']) + +# Simple approach: Easy to integrate +def get_project_context(project_path): + # Guaranteed to work + result = subprocess.run(['rstring', '-C', project_path, '--preset', 'common']) +``` + +## Power Loss Analysis + +**What power does the complex approach provide that simple approaches don't?** + +1. **Automatic path detection** - But this is unreliable and creates more problems than it solves +2. **"Magic" behavior** - But magic that fails is worse than explicit behavior that works + +**Conclusion: The complex approach provides zero additional real power.** + +## Composability Enhancement Examples + +### 1. Pipeline Integration +```bash +# Simple approach enables powerful pipelines +git ls-files --others --ignored --exclude-standard | \ + grep -E '\.(py|js|ts)$' | \ + head -10 | \ + xargs -I {} dirname {} | \ + sort -u | \ + xargs -I {} rstring -C {} --preset common +``` + +### 2. Parallel Processing +```bash +# Simple approach enables safe parallelization +find . -name "*.project" -type d | \ + parallel rstring -C {} --preset common +``` + +### 3. Configuration Management +```bash +# Simple approach enables configuration-driven workflows +while IFS= read -r project_config; do + project_path=$(echo "$project_config" | cut -d: -f1) + preset_name=$(echo "$project_config" | cut -d: -f2) + rstring -C "$project_path" --preset "$preset_name" +done < project_list.txt +``` + +## Implementation Strategy for Maximum Power + +### Recommended Implementation +```python +def parse_target_directory(args): + """Maximum power with maximum reliability.""" + parser = argparse.ArgumentParser() + parser.add_argument('-C', '--directory', help='Change to directory before processing') + parser.add_argument('target', nargs='?', help='Target directory (optional)') + + parsed, remaining = parser.parse_known_args(args) + + if parsed.directory: + return os.path.abspath(parsed.directory), remaining + elif parsed.target and os.path.isdir(parsed.target): + return os.path.abspath(parsed.target), remaining + else: + return os.getcwd(), args +``` + +### Power Features to Add +1. **Multiple target support:** + ```bash + rstring -C /proj1 -C /proj2 --preset common # Process multiple projects + ``` + +2. **Output aggregation:** + ```bash + rstring -C /proj1 --output=proj1.txt --preset common + ``` + +3. **Template support:** + ```bash + rstring -C /project --template=ai-context --preset common + ``` + +## Conclusion + +**The simple approaches provide 100% of the power with significantly enhanced composability and reliability.** The complex `determine_target_directory` approach is a false economy—it promises convenience but delivers fragility. + +### Power Scorecard +- **Rsync filtering power**: Simple ✅ = Complex ✅ +- **Preset system power**: Simple ✅ > Complex ⚠️ (more reliable) +- **Shell composability**: Simple ✅✅ >> Complex ⚠️ (much better) +- **Automation potential**: Simple ✅✅ >> Complex ❌ (much better) +- **Cross-platform reliability**: Simple ✅✅ >> Complex ❌ (much better) +- **Debugging simplicity**: Simple ✅✅ >> Complex ❌ (much better) + +**Result: The simple approach is strictly superior in power, composability, and reliability.** + +The key insight is that **explicit is more powerful than implicit** when the implicit behavior is unreliable. Users get more leverage from predictable tools they can compose reliably than from "smart" tools that sometimes fail in mysterious ways. \ No newline at end of file diff --git a/defunct/stringify-old.py b/defunct/stringify-old.py new file mode 100755 index 0000000..810af4e --- /dev/null +++ b/defunct/stringify-old.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python + +import argparse +import fnmatch +import os + +def main(): + parser = argparse.ArgumentParser(description='Gather code from a directory into a single string with file paths labeled.') + parser.add_argument('directory', type=str, help='Root directory to gather code from') + parser.add_argument('-o', '--output', type=str, help='Output file (default is stdout)', default=None) + parser.add_argument('-i', '--include', action='append', help='Include patterns (glob format, repeatable)', default=["*"]) + parser.add_argument('-e', '--exclude', action='append', help='Exclude patterns (glob format, repeatable)', default=[]) + + args = parser.parse_args() + + gather_code(args.directory, args.output, args.include, args.exclude) + +def gather_code(directory, output_file, includes, excludes): + result = "" + for root, dirs, files in os.walk(directory, topdown=True): + # Exclude specified directories + dirs[:] = [d for d in dirs if not any(fnmatch.fnmatch(os.path.join(root, d), pat) for pat in excludes)] + + # Apply include patterns + if includes: + files = [f for f in files if any(fnmatch.fnmatch(os.path.join(root, f), pat) for pat in includes)] + + # Exclude specified files + relative_root = os.path.relpath(root, start=directory) + files = [f for f in files if not any(fnmatch.fnmatch(os.path.join(relative_root, f), pat) for pat in excludes)] + + for file in files: + file_path = os.path.join(root, file) + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as file_content: + file_data = file_content.read() + result += f'--- {os.path.relpath(file_path, start=directory)} ---\n{file_data}\n' + except Exception as e: + print(f"Error reading {file_path}: {e}") + + if output_file: + with open(output_file, 'w', encoding='utf-8') as f: + f.write(result) + else: + print(result) + +if __name__ == '__main__': + main() + diff --git a/defunct/stringify.py.before b/defunct/stringify.py.before new file mode 100755 index 0000000..4794cd1 --- /dev/null +++ b/defunct/stringify.py.before @@ -0,0 +1,102 @@ +#!/usr/bin/env python +import argparse +import os +import fnmatch +import re + +def parse_arguments(): + parser = argparse.ArgumentParser(description='Gather code from a directory into a single string with file paths labeled.') + parser.add_argument('directory', type=str, help='Root directory to gather code from') + parser.add_argument('-o', '--output', type=str, help='Output file (default is stdout)', default=None) + parser.add_argument('patterns', nargs='*', help='Include/exclude patterns (prefix with - to exclude)') + return parser.parse_args() + +def is_binary(file_path): + """Check if file is binary""" + with open(file_path, 'rb') as file: + return b'\0' in file.read(1024) + +def match_path(path, pattern): + """Match a path against a pattern, supporting rsync-like wildcards""" + if pattern.startswith('/'): + pattern = pattern[1:] # Remove leading '/' for anchored patterns + else: + pattern = f'**/{pattern}' # Unanchored patterns can match anywhere in the path + + # Convert rsync-like pattern to regex + regex = fnmatch.translate(pattern) + regex = regex.replace(r'\Z(?ms)', '') # Remove end of string match + regex = regex.replace('**/', '(.*\/)*') # Support '**' wildcard + + return re.match(regex, path) is not None + +def should_include(path, patterns): + """Determine if a path should be included based on the patterns""" + include = True # Default to include + for pattern in patterns: + if pattern.startswith('-'): # Exclude pattern + if match_path(path, pattern[1:]): + include = False + elif match_path(path, pattern): # Include pattern + include = True + return include + +def gather_code(directory, patterns): + result = "" + for root, dirs, files in os.walk(directory, topdown=True): + rel_root = os.path.relpath(root, directory) + print(f"Processing directory: {rel_root}") # Debug output + + # Filter directories + dirs[:] = [d for d in dirs if should_include(os.path.join(rel_root, d + '/'), patterns)] + print(f"Dirs after filtering: {dirs}") # Debug output + + # Process files + for file in files: + file_path = os.path.join(rel_root, file) + print(f"Checking file: {file_path}") # Debug output + if should_include(file_path, patterns): + print(f"Including file: {file_path}") # Debug output + full_path = os.path.join(root, file) + try: + if is_binary(full_path): + result += f'--- {file_path} ---\n[Binary file]\n' + else: + with open(full_path, 'r', encoding='utf-8', errors='ignore') as file_content: + file_data = file_content.read() + result += f'--- {file_path} ---\n{file_data}\n' + except Exception as e: + print(f"Error reading {file_path}: {e}") + else: + print(f"Excluding file: {file_path}") # Debug output + + return result + +def main(): + args = parse_arguments() + + # Handle trailing slash in directory + if args.directory.endswith('/'): + base_dir = os.path.dirname(args.directory.rstrip('/')) + if not base_dir: + base_dir = '..' + patterns = ['*/'] + args.patterns + else: + base_dir = os.path.dirname(args.directory) + if not base_dir: + base_dir = '..' + patterns = [os.path.basename(args.directory)] + args.patterns + + print(f"Base directory: {base_dir}") # Debug output + print(f"Patterns: {patterns}") # Debug output + + result = gather_code(base_dir, patterns) + + if args.output: + with open(args.output, 'w', encoding='utf-8') as f: + f.write(result) + else: + print(result) + +if __name__ == '__main__': + main() diff --git a/defunct/stringify.py.lastbeforersync b/defunct/stringify.py.lastbeforersync new file mode 100755 index 0000000..afdaea4 --- /dev/null +++ b/defunct/stringify.py.lastbeforersync @@ -0,0 +1,128 @@ +# #!/usr/bin/env python +# import os +# import fnmatch +# import sys +# import logging +# +# logging.basicConfig(level=logging.DEBUG) +# logger = logging.getLogger(__name__) +# +# +# def parse_arguments(): +# if len(sys.argv) < 2: +# print("Usage: python stringify.py [patterns...]") +# sys.exit(1) +# +# directory = sys.argv[1] +# patterns = sys.argv[2:] +# return directory, patterns +# +# +# def is_binary(file_path): +# try: +# with open(file_path, 'rb') as file: +# return b'\0' in file.read(1024) +# except IOError: +# return False +# +# +# def match_pattern(path, pattern): +# logger.debug(f"Matching path: {path} against pattern: {pattern}") +# logger.debug(f"Path components: {path.split(os.sep)}") +# logger.debug(f"Pattern components: {pattern.split(os.sep)}") +# if pattern.startswith('/'): +# result = fnmatch.fnmatch('/' + path, pattern) +# elif '**' in pattern: +# parts = pattern.split('**') +# result = path.startswith(parts[0]) and path.endswith(parts[-1]) and all(part in path for part in parts[1:-1]) +# else: +# result = fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(os.path.basename(path), pattern) +# logger.debug(f"Match result: {result}") +# return result +# +# +# def build_file_list(directory, patterns): +# logger.debug(f"Building file list for directory: {directory}") +# logger.debug(f"Patterns: {patterns}") +# +# all_files = set() +# for root, dirs, files in os.walk(directory): +# for file in files: +# all_files.add(os.path.relpath(os.path.join(root, file), directory)) +# +# logger.debug(f"All files: {all_files}") +# +# include_only_mode = any(not p.startswith('-') for p in patterns) +# logger.debug(f"Include-only mode: {include_only_mode}") +# +# result = set() +# excluded = set() +# +# logger.debug(f"Patterns is {patterns}") +# for pattern in patterns: +# logger.debug(f"Processing pattern: {pattern}") +# is_exclude = pattern.startswith('-') +# logger.debug(f"Is exclude: {is_exclude}") +# pattern = pattern[1:] if is_exclude else pattern +# logger.debug(f"Pattern after stripping '-' if present: {pattern}") +# +# matched = set(f for f in all_files if match_pattern(f, pattern)) +# logger.debug(f"Matched files for pattern {pattern}: {matched}") +# +# if is_exclude: +# excluded.update(matched) +# # Exclude contents of matched directories +# for path in matched: +# if os.path.isdir(os.path.join(directory, path)): +# excluded.update(f for f in all_files if f.startswith(path + os.sep)) +# else: +# result.update(matched) +# for path in all_files: +# if match_pattern(path, pattern): +# if is_exclude: +# excluded.add(path) +# logger.debug(f"Excluded based on pattern {pattern}: {path}") +# else: +# result.add(path) +# logger.debug(f"Included based on pattern {pattern}: {path}") +# +# logger.debug(f"Files after pattern matching - Included: {result}, Excluded: {excluded}") +# +# if include_only_mode: +# final_result = result - excluded +# else: +# final_result = all_files - excluded +# final_result.update(result) +# +# logger.debug(f"Final result after mode application: {final_result}") +# return sorted(final_result) +# +# +# def gather_code(directory, file_list): +# result = "" +# for file_path in file_list: +# full_path = os.path.join(directory, file_path) +# if os.path.isfile(full_path): +# try: +# if is_binary(full_path): +# result += f'--- {file_path} ---\n[Binary file]\n\n' +# else: +# with open(full_path, 'r', encoding='utf-8', errors='ignore') as file_content: +# file_data = file_content.read() +# result += f'--- {file_path} ---\n{file_data}\n\n' +# except Exception as e: +# logger.error(f"Error reading {file_path}: {e}") +# return result +# +# +# def main(): +# directory, patterns = parse_arguments() +# directory = directory.rstrip('/') +# +# file_list = build_file_list(directory, patterns) +# result = gather_code(directory, file_list) +# print(result) +# +# +# if __name__ == '__main__': +# main() diff --git a/defunct/stringify.py.singlefile b/defunct/stringify.py.singlefile new file mode 100755 index 0000000..316e199 --- /dev/null +++ b/defunct/stringify.py.singlefile @@ -0,0 +1,279 @@ +#!/usr/bin/env python + +import argparse +import binascii +import json +import logging +import os +import platform +import shlex +import subprocess + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +PRESETS_FILE = os.path.expanduser("~/.stringify.yaml") + + +def load_presets(): + if os.path.exists(PRESETS_FILE): + try: + with open(PRESETS_FILE, 'r') as f: + return json.load(f) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in {PRESETS_FILE}. Using empty presets.") + print(f"Warning: Invalid JSON in {PRESETS_FILE}. Using empty presets.") + except Exception as e: + logger.error(f"Error reading {PRESETS_FILE}: {e}") + print(f"Warning: Error reading {PRESETS_FILE}. Using empty presets.") + return {} + + +def save_presets(presets): + with open(PRESETS_FILE, 'w') as f: + json.dump(presets, f, indent=2) + + +def check_rsync(): + try: + subprocess.run(["rsync", "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def run_rsync(args): + cmd = ["rsync", "-ain", "--list-only"] + args + logger.debug(f"Rsync command: {' '.join(cmd)}") + + try: + result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + logger.debug(f"Rsync stdout: {result.stdout}") + logger.debug(f"Rsync stderr: {result.stderr}") + return parse_rsync_output(result.stdout) + except subprocess.CalledProcessError as e: + logger.error(f"Rsync command failed: {e}") + logger.error(f"Stdout: {e.stdout}") + logger.error(f"Stderr: {e.stderr}") + raise + + +def validate_rsync_args(args): + try: + run_rsync(args) + return True + except subprocess.CalledProcessError: + return False + + +def parse_rsync_output(output): + file_list = [] + for line in output.splitlines(): + parts = line.split() + if len(parts) >= 5 and not line.endswith('/'): + file_path = ' '.join(parts[4:]) + if file_path != '.': # Exclude the root directory + file_list.append(file_path) + return file_list + + +def is_binary(file_path): + try: + with open(file_path, 'rb') as file: + return b'\0' in file.read(1024) + except IOError: + return False + + +def gather_code(file_list, preview_length=None): + result = "" + for file_path in file_list: + full_path = file_path + if os.path.isfile(full_path): + try: + with open(full_path, 'rb') as file_content: + if is_binary(full_path): + if preview_length is None or preview_length > 0: + file_data = f"[Binary file, first 32 bytes: {binascii.hexlify(file_content.read(32)).decode()}]" + else: + file_data = file_content.read().decode('utf-8', errors='ignore') + file_data = '\n'.join(file_data.splitlines()[:preview_length]) + if preview_length is None or preview_length > 0: + result += f"--- {file_path} ---\n{file_data}\n\n" + else: + result += f"--- {file_path} ---\n\n" + except Exception as e: + logger.error(f"Error reading {file_path}: {e}") + else: + result += f"--- {file_path} ---\n[Directory]\n\n" + return result + + +def interactive_mode(initial_args): + args = initial_args.copy() + while True: + print(args) + if not validate_rsync_args(args): + print("Error: Invalid rsync arguments. Please try again.") + continue + + file_list = run_rsync(args) + print("\nCurrent file list:") + print_tree(file_list) + # for file in file_list: + # if os.path.isfile(file): + # print(file) + print(f"\nCurrent rsync arguments: {' '.join(args)}") + + action = input("\nEnter an action (a)dd/(r)emove/(e)dit/(d)one: ").lower() + if action in ['done', 'd']: + break + elif action in ['add', 'a']: + pattern = input("Enter a pattern: ") + args.extend(['--include', pattern]) + elif action in ['remove', 'r']: + pattern = input("Enter a pattern: ") + args.extend(['--exclude', pattern]) + elif action in ['edit', 'e']: + args_str = input("Enter the new rsync arguments: ") + new_args = shlex.split(args_str) + if not any(arg for arg in new_args if not arg.startswith('--')): + new_args.append('.') + if validate_rsync_args(new_args): + args = new_args + else: + print("Error: Invalid rsync arguments. Please try again.") + else: + print("Invalid action. Please enter 'a', 'r', 'e', or 'd'.") + + return args + + +def print_tree(file_list): + tree = {} + for file_path in file_list: + parts = file_path.split(os.sep) + current = tree + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + current[parts[-1]] = {} + + def print_tree_recursive(node, prefix=""): + items = list(node.items()) + for i, (name, subtree) in enumerate(items): + if i == len(items) - 1: + print(f"{prefix}└── {name}") + new_prefix = prefix + " " + else: + print(f"{prefix}├── {name}") + new_prefix = prefix + "│ " + if subtree: + print_tree_recursive(subtree, new_prefix) + + print_tree_recursive(tree) + + +def copy_to_clipboard(text, file_list): + 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) + print(f"Copied {len(text)} chars from {len(text.splitlines())} lines from {len(file_list)} files to clipboard.") + except Exception as e: + print(f"Failed to copy to clipboard: {e}") + + +def main(): + if not check_rsync(): + print("Error: rsync is not installed on this system. Please install rsync and try again.") + return + + parser = argparse.ArgumentParser(description="Stringify code with rsync and manage presets.") + parser.add_argument("-p", "--preset", help="Use a saved preset") + parser.add_argument("-sp", "--save-preset", nargs=2, metavar=("NAME", "ARGS"), help="Save a new preset") + parser.add_argument("-sap", "--save-as-preset", metavar="NAME", help="Save the current 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("-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", help="Show only the first N lines of each file") + parser.add_argument("-s", "--summary", action="store_true", help="Print a summary including a tree of files") + # parser.add_argument('rsync_args', nargs=argparse.REMAINDER, help="Additional rsync arguments") + + args, unknown_args = parser.parse_known_args() + + presets = load_presets() + + if args.list_presets: + print("Saved presets:") + for name, preset_args in presets.items(): + print(f" {name}: {' '.join(preset_args)}") + return + + if args.save_preset: + name, preset_args = args.save_preset + presets[name] = shlex.split(preset_args) + save_presets(presets) + print(f"Preset '{name}' saved.") + 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.") + else: + print(f"Preset '{args.delete_preset}' not found.") + return + + rsync_args = presets.get(args.preset, []) if args.preset else [] + # rsync_args.extend(args.rsync_args) + rsync_args.extend(unknown_args) + + # Add default directory (.) if no source directory is provided + 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 + + if args.interactive: + rsync_args = interactive_mode(rsync_args) + + file_list = run_rsync(rsync_args) + result = gather_code(file_list, args.preview_length) + + if args.no_clipboard: + print(result) + else: + copy_to_clipboard(result, file_list) + + if args.summary: + if args.preset: + if rsync_args != presets[args.preset]: + print(f"Using preset '{args.preset}' with modified rsync options: {' '.join(rsync_args)}") + else: + print(f"Using preset '{args.preset}'") + else: + print(f"Using rsync options: {' '.join(rsync_args)}") + print("\nFile tree:") + print_tree(file_list) + + if args.save_as_preset: + presets[args.save_as_preset] = rsync_args + save_presets(presets) + print(f"Preset '{args.save_as_preset}' saved.") + + +if __name__ == '__main__': + main() diff --git a/defunct/test_stringify.py.before b/defunct/test_stringify.py.before new file mode 100755 index 0000000..000bea5 --- /dev/null +++ b/defunct/test_stringify.py.before @@ -0,0 +1,121 @@ +import os +import subprocess +import tempfile +import shutil +import unittest +import signal + +class TimeoutException(Exception): + pass + +def timeout_handler(signum, frame): + raise TimeoutException("Test timed out") + + +class TestStringify(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Create a temporary directory structure for testing + cls.test_dir = tempfile.mkdtemp() + cls.create_test_file_structure(cls.test_dir) + + @classmethod + def tearDownClass(cls): + # Clean up the temporary directory + shutil.rmtree(cls.test_dir) + + @classmethod + def create_test_file_structure(cls, root): + # Create a complex directory structure for testing + os.makedirs(os.path.join(root, "src", "lib")) + os.makedirs(os.path.join(root, "docs")) + os.makedirs(os.path.join(root, "tests")) + os.makedirs(os.path.join(root, "node_modules", "package")) + os.makedirs(os.path.join(root, "logs", "old")) + + # Create some test files + open(os.path.join(root, "file1.txt"), "w").close() + open(os.path.join(root, "file2.txt"), "w").close() + open(os.path.join(root, "src", "main.py"), "w").close() + open(os.path.join(root, "src", "lib", "util.py"), "w").close() + open(os.path.join(root, "docs", "readme.md"), "w").close() + open(os.path.join(root, "tests", "test_main.py"), "w").close() + open(os.path.join(root, "node_modules", "package", "index.js"), "w").close() + open(os.path.join(root, "logs", "app.log"), "w").close() + open(os.path.join(root, "logs", "old", "app_2023.log"), "w").close() + + def run_stringify(self, *args, timeout=5): + cmd = ["python", "stringify.py", self.test_dir] + list(args) + + signal.signal(signal.SIGALRM, timeout_handler) + signal.alarm(timeout) + + try: + result = subprocess.run(cmd, capture_output=True, text=True) + signal.alarm(0) # Cancel the alarm + return result.stdout + except TimeoutException: + self.fail(f"Test timed out after {timeout} seconds") + + def test_default_behavior(self): + output = self.run_stringify() + self.assertIn("file1.txt", output) + self.assertIn("src/main.py", output) + self.assertIn("node_modules/package/index.js", output) + + def test_single_include(self): + output = self.run_stringify("-i", "*.txt") + self.assertIn("file1.txt", output) + self.assertIn("file2.txt", output) + self.assertNotIn("src/main.py", output) + + def test_single_exclude(self): + output = self.run_stringify("-e", "*.log") + self.assertIn("file1.txt", output) + self.assertNotIn("logs/app.log", output) + self.assertNotIn("logs/old/app_2023.log", output) + + def test_include_then_exclude(self): + output = self.run_stringify("-i", "*.py", "-e", "test_*.py") + self.assertIn("src/main.py", output) + self.assertNotIn("tests/test_main.py", output) + + def test_exclude_then_include(self): + output = self.run_stringify("-e", "*.py", "-i", "src/*.py") + self.assertIn("src/main.py", output) + self.assertNotIn("tests/test_main.py", output) + + def test_double_asterisk(self): + output = self.run_stringify("-i", "**/*.py") + self.assertIn("src/main.py", output) + self.assertIn("src/lib/util.py", output) + self.assertIn("tests/test_main.py", output) + + def test_exclude_directory(self): + output = self.run_stringify("-e", "node_modules/") + self.assertIn("file1.txt", output) + self.assertNotIn("node_modules/package/index.js", output) + + def test_include_file_in_excluded_directory(self): + output = self.run_stringify("-e", "node_modules/", "-i", "node_modules/package/index.js") + self.assertIn("node_modules/package/index.js", output) + self.assertNotIn("node_modules/some_other_file.js", output) + + def test_anchored_path(self): + output = self.run_stringify("-e", "/logs/old/*.log") + self.assertIn("logs/app.log", output) + self.assertNotIn("logs/old/app_2023.log", output) + + def test_unanchored_path(self): + output = self.run_stringify("-e", "*/old/*") + self.assertIn("logs/app.log", output) + self.assertNotIn("logs/old/app_2023.log", output) + + def test_complex_scenario(self): + output = self.run_stringify("-i", "*.py", "-e", "test_*.py", "-i", "test_critical*.py", "-e", "**/deprecated/**") + self.assertIn("src/main.py", output) + self.assertNotIn("tests/test_main.py", output) + # We would need to add a test_critical.py file to fully test this scenario + +if __name__ == '__main__': + unittest.main() diff --git a/defunct/test_stringify.py.disabled b/defunct/test_stringify.py.disabled new file mode 100644 index 0000000..f87e430 --- /dev/null +++ b/defunct/test_stringify.py.disabled @@ -0,0 +1,154 @@ +import logging +import os +import shutil +import subprocess +import unittest + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +class TestStringify(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.test_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'test_data')) + cls.create_test_file_structure(cls.test_dir) + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.test_dir) + + @classmethod + def create_test_file_structure(cls, root): + os.makedirs(os.path.join(root, "src", "lib")) + os.makedirs(os.path.join(root, "docs")) + os.makedirs(os.path.join(root, "tests")) + os.makedirs(os.path.join(root, "node_modules", "package")) + os.makedirs(os.path.join(root, "logs", "old")) + + open(os.path.join(root, "file1.txt"), "w").close() + open(os.path.join(root, "file2.txt"), "w").close() + open(os.path.join(root, "src", "main.py"), "w").close() + open(os.path.join(root, "src", "lib", "util.py"), "w").close() + open(os.path.join(root, "docs", "readme.md"), "w").close() + open(os.path.join(root, "tests", "test_main.py"), "w").close() + open(os.path.join(root, "node_modules", "package", "index.js"), "w").close() + open(os.path.join(root, "logs", "app.log"), "w").close() + open(os.path.join(root, "logs", "old", "app_2023.log"), "w").close() + + def run_stringify(self, *args): + cmd = ["python", "stringify.py", self.test_dir] + list(args) + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout, result.stderr, result.returncode + + def assertInWithContext(self, member, container, msg=None): + try: + self.assertIn(member, container[0], msg) + except AssertionError: + logger.debug( + f"\nFailed test command: python stringify.py {self.test_dir} {' '.join(self._testMethodName.split('_')[1:])}") + logger.debug(f"STDOUT:\n{container[0]}") + logger.debug(f"STDERR:\n{container[1]}") + raise + + def assertNotInWithContext(self, member, container, msg=None): + try: + self.assertNotIn(member, container[0], msg) + except AssertionError: + logger.debug( + f"\nFailed test command: python stringify.py {self.test_dir} {' '.join(self._testMethodName.split('_')[1:])}") + logger.debug(f"STDOUT:\n{container[0]}") + logger.debug(f"STDERR:\n{container[1]}") + raise + + # def test_default_behavior(self): + # output = self.run_stringify() + # self.assertInWithContext("file1.txt", output) + # self.assertInWithContext("src/main.py", output) + # self.assertInWithContext("node_modules/package/index.js", output) + # + # def test_single_include(self): + # output = self.run_stringify("*.txt") + # self.assertInWithContext("file1.txt", output) + # self.assertInWithContext("file2.txt", output) + # self.assertNotInWithContext("src/main.py", output) + # + # def test_single_exclude(self): + # output = self.run_stringify("-*.log") + # self.assertInWithContext("file1.txt", output) + # self.assertNotInWithContext("logs/app.log", output) + # self.assertNotInWithContext("logs/old/app_2023.log", output) + # + # def test_include_then_exclude(self): + # output = self.run_stringify("*.py", "-test_*.py") + # self.assertInWithContext("src/main.py", output) + # self.assertNotInWithContext("tests/test_main.py", output) + # + # def test_exclude_then_include(self): + # output = self.run_stringify("-*.py", "src/*.py") + # self.assertInWithContext("src/main.py", output) + # self.assertNotInWithContext("tests/test_main.py", output) + # + # def test_double_asterisk(self): + # output = self.run_stringify("**/*.py") + # self.assertInWithContext("src/main.py", output) + # self.assertInWithContext("src/lib/util.py", output) + # self.assertInWithContext("tests/test_main.py", output) + + def test_exclude_directory(self): + output = self.run_stringify("-node_modules") + self.assertInWithContext("file1.txt", output) + self.assertNotInWithContext("node_modules/package/index.js", output) + # + # def test_include_file_in_excluded_directory(self): + # output = self.run_stringify("-node_modules", "node_modules/package/index.js") + # self.assertInWithContext("node_modules/package/index.js", output) + # self.assertNotInWithContext("node_modules/some_other_file.js", output) + # + # def test_anchored_path(self): + # output = self.run_stringify("-/logs/old/*.log") + # self.assertInWithContext("logs/app.log", output) + # self.assertNotInWithContext("logs/old/app_2023.log", output) + # + # def test_unanchored_path(self): + # output = self.run_stringify("-*/old/*") + # self.assertInWithContext("logs/app.log", output) + # self.assertNotInWithContext("logs/old/app_2023.log", output) + # + # def test_complex_scenario(self): + # output = self.run_stringify("*.py", "src/**", "-test_*.py", "test_critical*.py", "-**/deprecated/**") + # self.assertInWithContext("src/main.py", output) + # self.assertNotInWithContext("tests/test_main.py", output) + # + # # New test cases + # def test_include_directory_contents(self): + # output = self.run_stringify("src/", "src/**") + # self.assertInWithContext("src/main.py", output) + # self.assertInWithContext("src/lib/util.py", output) + # self.assertNotInWithContext("file1.txt", output) + # + # def test_exclude_directory_include_subdirectory(self): + # output = self.run_stringify("-logs/", "logs/old/", "logs/old/**") + # self.assertNotInWithContext("logs/app.log", output) + # self.assertInWithContext("logs/old/app_2023.log", output) + # + # def test_include_only_mode(self): + # output = self.run_stringify("*.py") + # self.assertInWithContext("src/main.py", output) + # self.assertNotInWithContext("file1.txt", output) + # self.assertNotInWithContext("logs/app.log", output) + # + # def test_parent_directory_inclusion(self): + # output = self.run_stringify("src/lib/util.py") + # self.assertInWithContext("src/lib/util.py", output) + # self.assertNotInWithContext("src/main.py", output) + # + # def test_multiple_patterns(self): + # output = self.run_stringify("*.txt", "*.py", "-test_*.py") + # self.assertInWithContext("file1.txt", output) + # self.assertInWithContext("src/main.py", output) + # self.assertNotInWithContext("tests/test_main.py", output) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/defunct/test_stringify_rsync.py.disabled b/defunct/test_stringify_rsync.py.disabled new file mode 100644 index 0000000..e16a1bb --- /dev/null +++ b/defunct/test_stringify_rsync.py.disabled @@ -0,0 +1,72 @@ +import unittest +import os +import shutil +import subprocess +import tempfile +import json + +class TestStringifyRsync(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.test_dir = tempfile.mkdtemp() + cls.create_test_file_structure(cls.test_dir) + cls.script_path = os.path.abspath("stringify_rsync.py") + cls.test_preset_file = os.path.join(cls.test_dir, ".test_presets.json") + os.environ["STRINGIFY_PRESET_FILE"] = cls.test_preset_file + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.test_dir) + if os.path.exists(cls.test_preset_file): + os.remove(cls.test_preset_file) + + @classmethod + def create_test_file_structure(cls, root): + os.makedirs(os.path.join(root, "src")) + os.makedirs(os.path.join(root, "tests")) + + files = [ + "file1.txt", "file2.py", + os.path.join("src", "main.py"), + os.path.join("../tests", "test_main.py") + ] + + for file in files: + with open(os.path.join(root, file), "w") as f: + f.write(f"Content of {file}") + + def run_stringify(self, *args): + cmd = ["python", self.script_path, self.test_dir] + list(args) + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout, result.stderr, result.returncode + + def test_default_behavior(self): + output, _, _ = self.run_stringify() + self.assertIn("file1.txt", output) + self.assertIn("file2.py", output) + self.assertIn("src/main.py", output) + self.assertIn("tests/test_main.py", output) + + def test_save_and_use_preset(self): + _, _, rc = self.run_stringify("--save-preset", "py_only", "--include=*.py --exclude=*") + self.assertEqual(rc, 0) + + output, _, _ = self.run_stringify("--preset", "py_only") + self.assertNotIn("file1.txt", output) + self.assertIn("file2.py", output) + self.assertIn("src/main.py", output) + self.assertIn("tests/test_main.py", output) + + def test_list_presets(self): + self.run_stringify("--save-preset", "test_preset", "--include=*.txt") + output, _, _ = self.run_stringify("--list-presets") + self.assertIn("test_preset", output) + + def test_delete_preset(self): + self.run_stringify("--save-preset", "to_delete", "--include=*.txt") + self.run_stringify("--delete-preset", "to_delete") + output, _, _ = self.run_stringify("--list-presets") + self.assertNotIn("to_delete", output) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/nothin/emptyfile.txt b/nothin/emptyfile.txt new file mode 100644 index 0000000..e69de29 diff --git a/nothin/nonemptyfile.txt b/nothin/nonemptyfile.txt new file mode 100644 index 0000000..45b983b --- /dev/null +++ b/nothin/nonemptyfile.txt @@ -0,0 +1 @@ +hi diff --git a/rstring/cli.py b/rstring/cli.py index b1198b3..f9ac9ea 100644 --- a/rstring/cli.py +++ b/rstring/cli.py @@ -12,6 +12,42 @@ 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 main(): if not check_rsync(): print("Error: rsync is not installed on this system. Please install rsync and try again.") @@ -23,6 +59,7 @@ def main(): 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.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", @@ -40,7 +77,8 @@ def main(): 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'])}") + args_str = ' '.join(preset.get('args', [])) if preset.get('args') else '(no args)' + print(f" {'*' if preset.get('is_default', False) else ' '} {name}: {args_str}") return if args.delete_preset: @@ -56,18 +94,33 @@ def main(): set_default_preset(presets, args.set_default_preset) return - preset_name = args.preset or get_default_preset(presets) if not unknown_args else None + # 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: + target_dir, rsync_args_base = parse_target_directory(unknown_args) + except ValueError as e: + print(f"Error: {e}") + return + + # Validate target directory exists + if not os.path.isdir(target_dir): + print(f"Error: Directory '{target_dir}' does not exist.") + return + + # Handle presets + preset_name = args.preset or get_default_preset(presets) if not rsync_args_base else None if preset_name: preset = presets.get(preset_name) if preset: - rsync_args = preset['args'] + rsync_args = preset['args'] + rsync_args_base else: print(f"Error: Preset '{preset_name}' not found.") return else: - rsync_args = [] - - rsync_args.extend(unknown_args) + rsync_args = rsync_args_base if args.save_preset: name = args.save_preset @@ -76,29 +129,40 @@ def main(): print(f"Preset '{name}' saved.") return + # 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") + # 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 args.interactive: - rsync_args = interactive_mode(rsync_args, args.include_dirs) + if not validate_rsync_args(rsync_args): + print("Error: Invalid rsync arguments. Please check and try again.") + return - file_list = run_rsync(rsync_args) - result = gather_code(file_list, args.preview_length, 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) + + 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 result_with_summary = ["### COLLECTION SUMMARY ###", "", @@ -120,22 +184,24 @@ def main(): 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" + target_info = f" from {target_dir}" if target_dir != original_cwd else "" + 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)}") + print(f"{action}{target_info} using preset '{preset_name}' with modified rsync options: {' '.join(rsync_args)}") else: - print(f"{action} using preset '{preset_name}' modified by .gitignore") + print(f"{action}{target_info} using preset '{preset_name}' modified by .gitignore") else: - print(f"{action} using preset '{preset_name}'") + print(f"{action}{target_info} 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), '')}") + f"{action}{target_info} 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)}:") + print(f"{action}{target_info} using custom rsync options: {' '.join(rsync_args)}") if __name__ == '__main__': diff --git a/system_limits_audit.md b/system_limits_audit.md new file mode 100644 index 0000000..fdc214c --- /dev/null +++ b/system_limits_audit.md @@ -0,0 +1,90 @@ +# System Limits Audit Log + +## Session: 2024-12-30 - inotify Limits Optimization + +### System Specs +- **CPU**: AMD Ryzen 9 7900X 12-Core (24 threads) +- **RAM**: 124GB +- **Storage**: 1.4TB NVMe (71% used) +- **OS**: Kubuntu (Linux 6.14.0-15-generic) + +### Problem Identified +- `journalctl -f` failing with "Insufficient watch descriptors available" +- Webpack watch mode issues +- **Root cause**: `max_user_instances` limit (128) nearly exhausted (119/128 used) + +### Current Limits (Before Changes) +```bash +fs.inotify.max_user_instances = 128 +fs.inotify.max_user_watches = 1009491 +fs.inotify.max_queued_events = 16384 +ulimit -n = 16777216 (file descriptors - already optimal) +``` + +### Changes Applied + +#### 1. Created permanent inotify limits configuration ✅ +**File**: `/etc/sysctl.d/99-dev-limits.conf` +**Action**: Created new sysctl configuration file +**Timestamp**: 2024-12-30 + +```bash +# Development-optimized inotify limits for high-end workstation +# Applied: 2024-12-30 +# Reason: Fix journalctl -f failures and webpack watch issues + +# Increase inotify instances (was 128, now 2048) +fs.inotify.max_user_instances = 2048 + +# Increase queued events for rapid file changes (was 16384, now 65536) +fs.inotify.max_queued_events = 65536 + +# Keep existing watch limit (already adequate at ~1M) +# fs.inotify.max_user_watches = 1009491 +``` + +### Files Checked for Conflicts + +#### ✅ `/etc/sysctl.conf` +- **Status**: No inotify settings found - no conflicts + +#### ✅ `/etc/sysctl.d/*.conf` +- **Status**: No conflicting inotify settings in other files +- **Files present**: 10-bufferbloat.conf, 10-console-messages.conf, 10-ipv6-privacy.conf, 10-kernel-hardening.conf, 10-magic-sysrq.conf, 10-map-count.conf, 10-network-security.conf, 10-ptrace.conf, 10-zeropage.conf, 30-brave.conf, 99-dev-limits.conf + +#### ✅ `/etc/security/limits.conf` +- **Status**: Only nofile limits present (16777216) - no conflicts +- **Settings**: + ``` + * soft nofile 16777216 + * hard nofile 16777216 + ``` + +#### ✅ `/etc/systemd/system.conf` & `/etc/systemd/user.conf` +- **Status**: Default systemd limits commented out - no conflicts +- **Settings**: All DefaultLimit* entries are commented out (using defaults) + +### Verification Results ✅ + +```bash +# Applied limits verified: +fs.inotify.max_user_instances = 2048 ✅ (was 128) +fs.inotify.max_queued_events = 65536 ✅ (was 16384) + +# Unchanged (already adequate): +fs.inotify.max_user_watches = 1009491 +``` + +### Memory Impact +- **Before**: 128 instances × ~1KB = ~128KB +- **After**: 2048 instances × ~1KB = ~2MB +- **Impact**: Negligible on 124GB system + +### Next Steps +- [x] Test `journalctl -f` functionality +- [x] Monitor webpack watch mode stability +- [x] No reboot required (changes applied via `sysctl --system`) + +--- + +**Status**: ✅ COMPLETED - All limits successfully applied and verified \ No newline at end of file diff --git a/tests/empty/anything b/tests/empty/anything new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_rstring.py b/tests/test_rstring.py index ef6d6a7..c5cccbe 100644 --- a/tests/test_rstring.py +++ b/tests/test_rstring.py @@ -3,8 +3,21 @@ import pytest import yaml +import tempfile +import shutil +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__), '..')) from rstring import utils, cli +from rstring.utils import ( + load_presets, save_presets, 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, is_binary +) +from rstring.cli import parse_target_directory, main @pytest.fixture @@ -183,3 +196,84 @@ def test_main(temp_config): cli.main() mock_copy.assert_called_once() mock_copy.assert_called_with(mock_gathered_code) + + +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', '/tmp', '--include=*.py']) + assert target_dir == '/tmp' + assert remaining == ['--include=*.py'] + + # Test --directory= format + target_dir, remaining = parse_target_directory(['--directory=/tmp', '--include=*.py']) + assert target_dir == '/tmp' + assert remaining == ['--include=*.py'] + + # Test positional directory (when it exists) + with tempfile.TemporaryDirectory() as temp_dir: + target_dir, remaining = parse_target_directory([temp_dir, '--include=*.py']) + assert target_dir == os.path.abspath(temp_dir) + assert remaining == ['--include=*.py'] + + # Test no directory specified + target_dir, remaining = parse_target_directory(['--include=*.py']) + assert target_dir == os.path.abspath('.') + assert remaining == ['--include=*.py'] + + # Test error case + with pytest.raises(ValueError): + parse_target_directory(['-C']) + + +@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.load_presets') +@patch('rstring.cli.check_rsync') +def test_main_with_target_directory(mock_check_rsync, mock_load_presets, mock_get_tree_string, + mock_copy_to_clipboard, mock_gather_code, mock_run_rsync): + """Test main function with target directory functionality.""" + mock_check_rsync.return_value = True + mock_load_presets.return_value = {} + mock_run_rsync.return_value = ['test.py'] + mock_gather_code.return_value = 'test content' + mock_get_tree_string.return_value = 'tree' + + 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("hello")') + + # Test -C flag + with patch('sys.argv', ['rstring', '-C', temp_dir, '--include=*.py', '--no-clipboard']): + with patch('os.chdir') as mock_chdir: + main() + # Should change to target dir and then back to original + assert mock_chdir.call_count == 2 + mock_chdir.assert_any_call(temp_dir) + + # Test positional argument + with patch('sys.argv', ['rstring', temp_dir, '--include=*.py', '--no-clipboard']): + with patch('os.chdir') as mock_chdir: + main() + # Should change to target dir and then back to original + assert mock_chdir.call_count == 2 + mock_chdir.assert_any_call(temp_dir) + + +def test_main_with_nonexistent_directory(): + """Test main function with non-existent directory.""" + with patch('rstring.cli.check_rsync', return_value=True): + with patch('rstring.cli.load_presets', return_value={}): + with patch('sys.argv', ['rstring', '-C', '/nonexistent', '--include=*.py']): + with patch('builtins.print') as mock_print: + main() + mock_print.assert_called_with("Error: Directory '/nonexistent' does not exist.") From 691fea6df5b99a2a63c58aa929954ee31912ba54 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:00:22 -0500 Subject: [PATCH 02/12] feat: integrate git filtering and improve presets --- requirements-dev.txt | 1 + rstring/cli.py | 9 +++ rstring/default_presets.yaml | 140 +++++++++++++++++++++++++---------- rstring/git.py | 46 ++++++++++++ setup.py | 5 +- 5 files changed, 160 insertions(+), 41 deletions(-) create mode 100644 rstring/git.py 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 f9ac9ea..2d3eaf8 100644 --- a/rstring/cli.py +++ b/rstring/cli.py @@ -8,6 +8,8 @@ get_default_preset, set_default_preset, parse_gitignore ) +from .git import filter_ignored_files + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -155,6 +157,13 @@ def main(): rsync_args = interactive_mode(rsync_args, 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) diff --git a/rstring/default_presets.yaml b/rstring/default_presets.yaml index 69f408e..9693267 100644 --- a/rstring/default_presets.yaml +++ b/rstring/default_presets.yaml @@ -1,13 +1,88 @@ everything: is_default: false args: - - . common: is_default: true args: - # Common exclusions - - --exclude=.* + # Exclude common build and cache directories + - --exclude=.git + - --exclude=__pycache__/* + - --exclude=*.py[cod] + - --exclude=*$py.class + - --exclude=*.so + - --exclude=.Python + - --exclude=build/* + - --exclude=develop-eggs/* + - --exclude=dist/* + - --exclude=downloads/* + - --exclude=eggs/* + - --exclude=.eggs/* + - --exclude=lib/* + - --exclude=lib64/* + - --exclude=parts/* + - --exclude=sdist/* + - --exclude=var/* + - --exclude=wheels/* + - --exclude=pip-wheel-metadata/* + - --exclude=share/python-wheels/* + - --exclude=*.egg-info/* + - --exclude=.installed.cfg + - --exclude=*.egg + - --exclude=MANIFEST + + # PyInstaller + - --exclude=*.manifest + - --exclude=*.spec + + # Installer logs + - --exclude=pip-log.txt + - --exclude=pip-delete-this-directory.txt + + # Unit test / coverage reports + - --exclude=htmlcov/* + - --exclude=.tox/* + - --exclude=.nox/* + - --exclude=.coverage + - --exclude=.coverage.* + - --exclude=.cache + - --exclude=nosetests.xml + - --exclude=coverage.xml + - --exclude=*.cover + - --exclude=*.py,cover + - --exclude=.hypothesis/* + - --exclude=.pytest_cache/* + + # Environments + - --exclude=.env + - --exclude=.venv + - --exclude=env/* + - --exclude=venv/* + - --exclude=ENV/* + - --exclude=env.bak/* + - --exclude=venv.bak/* + + # mypy + - --exclude=.mypy_cache/* + - --exclude=.dmypy.json + - --exclude=dmypy.json + + # Pyre type checker + - --exclude=.pyre/* + + # macOS + - --exclude=.DS_Store + - --exclude=.DS_Store? + - --exclude=._* + - --exclude=.Spotlight-V100 + - --exclude=.Trashes + - --exclude=ehthumbs.db + - --exclude=Thumbs.db + + # IDEs + - --exclude=.idea/* + + # Common patterns - --exclude=.*/ - --exclude=*.log - --exclude=*.bak @@ -18,18 +93,12 @@ common: - --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 @@ -42,6 +111,8 @@ common: - --exclude=.mypy_cache - --exclude=.coverage - --exclude=htmlcov + + # Node.js - --exclude=node_modules - --exclude=bower_components - --exclude=dist @@ -50,12 +121,16 @@ common: - --exclude=.nuxt - --exclude=*.min.js - --exclude=*.min.css + + # Java - --exclude=vendor/ - --exclude=*.class - --exclude=*.jar - --exclude=*.war - --exclude=*.ear - --exclude=target/ + + # C/C++ - --exclude=*.o - --exclude=*.a - --exclude=*.so @@ -75,7 +150,7 @@ common: - --exclude=Pipfile.lock - --exclude=Cargo.lock - # Potential secret files + # Secrets and keys - --exclude=*.env - --exclude=.env* - --exclude=*.pem @@ -85,7 +160,7 @@ common: - --exclude=*.crt - --exclude=*.cer - # Documentation and media files + # Binary files - --exclude=*.pdf - --exclude=*.doc - --exclude=*.docx @@ -105,10 +180,10 @@ common: - --exclude=*.avi - --exclude=*.mov - # Include all directory paths + # Include directories first - --include=*/ - # Include common source code files + # Include common source files - --include=*.py - --include=*.ipynb - --include=*.js @@ -146,7 +221,7 @@ common: - --include=*.zsh - --include=*.sql - # Include common configuration and data files + # Include configuration files - --include=*.ini - --include=*.cfg - --include=*.conf @@ -154,8 +229,6 @@ common: - --include=*.yaml - --include=*.yml - --include=*.json - - # Include specific important configuration files - --include=.gitignore - --include=.dockerignore - --include=requirements.txt @@ -169,12 +242,12 @@ common: - --include=Cargo.toml - --include=.editorconfig - # Include documentation files + # Include documentation - --include=*.md - --include=*.rst - --include=*.txt - # Include other important files + # Include build files - --include=Dockerfile - --include=*.dockerfile - --include=Makefile @@ -185,42 +258,29 @@ common: # 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 directories - --include=*/ - # Include only documentation files + # Include documentation files - --include=*.md - --include=*.rst - --include=*.txt - --include=*.adoc - - --include=*.docx - - --include=*.pdf + - --include=*.asciidoc - # Include specific documentation files - - --include=README* - - --include=CONTRIBUTING* - - --include=CHANGELOG* - - --include=LICENSE* - - # Include documentation directories - - --include=docs/*** - - --include=documentation/*** - - --include=wiki/*** + # Include configuration files that might contain documentation + - --include=*.yaml + - --include=*.yml + - --include=*.json + - --include=*.toml - # Prune other directories to speed up the process + # Prune empty directories - --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/setup.py b/setup.py index 26e3810..03b9c18 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,10 @@ "pyyaml>=6.0.1", ], extras_require={ - 'dev': ['pytest>=8.3.2'], + 'dev': [ + 'pytest>=8.3.2', + 'hypothesis>=6.108.5', + ] }, package_data={ "rstring": ["default_presets.yaml"], From 179e3e24fdf2028d1007be4a069b94955f0a966f Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:05:34 -0500 Subject: [PATCH 03/12] docs: add focused CONTRIBUTING.md and improve README structure --- CONTRIBUTING.md | 41 +++++++++++++++++++++++++++++++++++++++++ README.md | 12 ++++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a198570 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing to Rstring + +Thanks for your interest in contributing! Here's how to get started: + +## Development Setup + +1. **Clone and setup**: + ```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 locally**: + ```bash + python -m rstring [options] + ``` + +## 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..fe5b108 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ Basic usage: rstring # Use the default preset ``` +Work with different directories: +```bash +rstring /path/to/project # Analyze a specific directory +rstring -C /path/to/project --preset my_preset # Change directory with preset +``` + +Custom filtering: ```bash rstring --include=*/ --include=*.py --exclude=* # traverse all dirs, include .py files, exclude everything else ``` @@ -140,8 +147,9 @@ rstring -i ## 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 From 8431e87e49bd873b0063abab85fe39163ed795c7 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:06:26 -0500 Subject: [PATCH 04/12] docs: fix development usage instructions for global access --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a198570..d20fc17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,10 @@ Thanks for your interest in contributing! Here's how to get started: 4. **Use locally**: ```bash + # After pip install -e ., you can use rstring from anywhere: + rstring [options] + + # Or if that doesn't work, use the module form: python -m rstring [options] ``` From 3f6c836c8f8b47adf45077bc5693ebeebc9c7178 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:08:41 -0500 Subject: [PATCH 05/12] docs: add proper dev/prod version switching workflow --- CONTRIBUTING.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d20fc17..9f9ef95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,12 +4,12 @@ Thanks for your interest in contributing! Here's how to get started: ## Development Setup -1. **Clone and 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 + python -m venv venv-dev + source venv-dev/bin/activate # Windows: venv-dev\Scripts\activate ``` 2. **Install for development**: @@ -23,13 +23,21 @@ Thanks for your interest in contributing! Here's how to get started: pytest ``` -4. **Use locally**: +4. **Use development version**: ```bash - # After pip install -e ., you can use rstring from anywhere: + # With dev environment activated + rstring [options] # Uses your development code + ``` + +5. **Switch between versions**: + ```bash + # Use development version + source venv-dev/bin/activate rstring [options] - # Or if that doesn't work, use the module form: - python -m rstring [options] + # Use production version + deactivate + rstring [options] # Uses globally installed version ``` ## Guidelines From 8a80bddcbf143dbd9fb01bdd9a1ab77291caace8 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:09:22 -0500 Subject: [PATCH 06/12] docs: use standard venv naming --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f9ef95..ef879a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ Thanks for your interest in contributing! Here's how to get started: ```bash git clone https://github.com/tnunamak/rstring.git cd rstring - python -m venv venv-dev - source venv-dev/bin/activate # Windows: venv-dev\Scripts\activate + python -m venv venv + source venv/bin/activate # Windows: venv\Scripts\activate ``` 2. **Install for development**: @@ -32,7 +32,7 @@ Thanks for your interest in contributing! Here's how to get started: 5. **Switch between versions**: ```bash # Use development version - source venv-dev/bin/activate + source venv/bin/activate rstring [options] # Use production version From d6a0091d96e604b358f95baca15db4349edacb65 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:37:33 -0500 Subject: [PATCH 07/12] docs: add comprehensive preset system analysis --- PRESET_SYSTEM_ANALYSIS.md | 311 +++++++++++++++++++++++++++++++ UX_ANALYSIS.md | 376 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 687 insertions(+) create mode 100644 PRESET_SYSTEM_ANALYSIS.md create mode 100644 UX_ANALYSIS.md diff --git a/PRESET_SYSTEM_ANALYSIS.md b/PRESET_SYSTEM_ANALYSIS.md new file mode 100644 index 0000000..495200b --- /dev/null +++ b/PRESET_SYSTEM_ANALYSIS.md @@ -0,0 +1,311 @@ +# Preset System Analysis: A Deep Dive into Leverage, UX, and Engineering Trade-offs + +## Executive Summary + +After extensive analysis of rstring's preset system, we've identified a fundamental tension between **user convenience** and **engineering leverage**. The current user-managed preset system solves real problems but at a high complexity cost. Through systematic evaluation of alternatives, we've converged on a **lean core + shell aliases** approach that maximizes leverage while maintaining full power and customizability. + +## The Original Problem Statement + +### What the Preset System Attempts to Solve + +1. **Rsync Pattern Complexity**: Raw rsync include/exclude syntax is cryptic and error-prone + ```bash + # This is intimidating and hard to get right: + rstring --include=*/ --include=*.py --exclude=test* --exclude=__pycache__/ --exclude=* + ``` + +2. **Repetitive Command Construction**: Users need the same complex patterns repeatedly +3. **Knowledge Sharing**: Teams need consistent file selection patterns +4. **Cognitive Load**: Abstract complex patterns into memorable names + +### Real-World Pain Points + +**Without presets:** +```bash +# User must type this every time for Python projects: +rstring --include=*/ --include=*.py --include=*.md --exclude=test* --exclude=__pycache__/ --exclude=* +``` + +**With presets:** +```bash +rstring --preset python # 20 characters vs 100+ +``` + +## Current System Analysis + +### Architectural Assessment + +**Strengths:** +- ✅ **Conceptually Sound**: Save complex commands as named shortcuts +- ✅ **Rsync Passthrough**: Zero reimplementation of filtering logic +- ✅ **Composable**: Presets + ad-hoc args work together +- ✅ **Team Shareable**: YAML config can be version controlled + +**Critical Weaknesses:** +- ❌ **Discovery Problem**: New users can't find or understand presets +- ❌ **Cognitive Overhead**: Must learn meta-system before using tool +- ❌ **UX Complexity**: Multiple preset management commands +- ❌ **Code Complexity**: YAML parsing, file I/O, CRUD operations + +### Leverage Analysis of Current System + +**Code Complexity**: ~100 lines across multiple files +- YAML file handling +- Preset CRUD operations +- Default preset management +- Error handling for preset operations + +**User Cognitive Load**: High +- Must understand preset concept +- Must learn preset management commands +- Must remember preset names +- Must understand preset composition with other args + +**Utility Provided**: Medium +- Saves typing for complex commands +- Enables team consistency +- Reduces errors in rsync pattern construction + +**Leverage Score**: Medium (Utility/Complexity = Medium/High) + +## Alternative Approaches Evaluated + +### Approach 1: Smart Defaults with Project Detection + +**Concept:** +```python +def get_smart_defaults(directory): + if os.path.exists('requirements.txt'): return ['--include=*/', '--include=*.py'] + if os.path.exists('package.json'): return ['--include=*/', '--include=*.js', '--include=*.ts'] + return ['--include=*/'] # Conservative fallback +``` + +**Leverage Analysis:** +- **Code**: ~30 lines +- **UX**: Zero configuration, immediate utility +- **Problems**: Makes assumptions about user preferences, limited flexibility + +**Verdict**: Higher leverage than current system, but makes too many assumptions + +### Approach 2: Built-in, Non-Editable Profiles + +**Concept:** +```bash +rstring --profile python # Built-in patterns for Python +rstring --profile web # Built-in patterns for web development +``` + +**Leverage Analysis:** +- **Code**: ~20 lines (simple dictionary lookup) +- **UX**: Easy discovery, no management overhead +- **Problems**: Users can't customize profiles, may not match needs + +**Verdict**: Good leverage, but limited user control + +### Approach 3: Conservative Default + Shell Aliases + +**Concept:** +```bash +# rstring provides simple, unopinionated default +rstring # Uses --include=*/ + gitignore filtering + +# Users create their own aliases for complex needs +alias rstring-py="rstring --include=*/ --include=*.py --exclude=test*" +``` + +**Leverage Analysis:** +- **Code**: ~5 lines (minimal default logic) +- **UX**: Immediate utility + user-controlled customization +- **Power**: Full rsync capabilities + user environment leverage + +**Verdict**: Highest leverage approach + +## The Gitignore Integration Insight + +### What Gitignore Handles Well +- ✅ **Build artifacts**: `__pycache__/`, `dist/`, `build/` +- ✅ **Dependencies**: `node_modules/`, `venv/` +- ✅ **IDE files**: `.idea/`, `.vscode/` +- ✅ **OS files**: `.DS_Store`, `Thumbs.db` + +### Where Gitignore Falls Short +- ❌ **Inclusion patterns**: Gitignore is exclusion-only +- ❌ **File type selection**: Can't express "only Python files" +- ❌ **Context-specific filtering**: Can't distinguish between "source for AI" vs "all project files" + +### Key Insight: Universal Exclusions Are Redundant + +Analysis of real gitignore files shows that proposed "universal exclusions" like `__pycache__/` and `node_modules/` are already covered by well-maintained gitignore files. Adding our own exclusions would create redundancy and maintenance overhead. + +**Recommendation**: Rely 100% on gitignore for exclusions, focus on inclusion patterns. + +## The Leverage Deep Dive + +### Defining Leverage in This Context + +**Leverage = Utility Provided / (Code Complexity + User Cognitive Load)** + +### Current Preset System Leverage Breakdown + +**Utility Provided:** +- Saves typing: High value for complex patterns +- Reduces errors: Medium value (rsync patterns are tricky) +- Enables sharing: Low-medium value (team coordination) +- Reduces learning: Negative value (adds learning overhead) + +**Code Complexity:** +- YAML handling: ~20 lines +- File I/O operations: ~15 lines +- Preset CRUD: ~30 lines +- CLI integration: ~20 lines +- Error handling: ~15 lines +- **Total**: ~100 lines + +**User Cognitive Load:** +- Learning preset concept: High +- Learning management commands: High +- Remembering preset names: Medium +- Understanding composition: Medium + +**Overall Leverage**: Medium-Low + +### Shell Aliases Approach Leverage Breakdown + +**Utility Provided:** +- Saves typing: High value (same as presets) +- Reduces errors: High value (same as presets) +- Enables sharing: High value (shell configs are shareable) +- Reduces learning: High value (leverages existing shell knowledge) + +**Code Complexity:** +- Default pattern logic: ~5 lines +- **Total**: ~5 lines + +**User Cognitive Load:** +- Learning shell aliases: Low (standard Unix knowledge) +- Creating custom aliases: Low (one-time setup) +- No rstring-specific concepts: Zero + +**Overall Leverage**: Very High + +## The "Do Users Want Docs/Config Files?" Question + +### Analysis of User Intent + +When users say "get my Python code," they might mean: +1. **Source only**: Just `.py` files in `src/`, exclude tests +2. **All Python**: Every `.py` file including tests and scripts +3. **Development context**: Python + docs + config files +4. **Everything relevant**: All files not in gitignore + +### The Assumption Problem + +Any built-in pattern makes assumptions about user intent: +- `--include=*.py` assumes they want all Python files +- `--include=src/` assumes specific directory structure +- Excluding tests assumes they don't want test context + +### The Conservative Solution + +**Default to maximum inclusion** (`--include=*/` + gitignore filtering): +- ✅ Makes zero assumptions about user preferences +- ✅ Respects project's own relevance decisions (gitignore) +- ✅ Easy to refine with additional flags +- ✅ Educational (shows what's in the project) + +## The Final Recommendation: Lean Core + Shell Aliases + +### Core Implementation + +```python +def get_default_patterns(): + """Conservative default: include everything, let gitignore filter.""" + return ['--include=*/'] + +# Usage in main(): +if not user_provided_patterns: + rsync_args = get_default_patterns() +``` + +### User Guidance + +**In README.md and --help:** +```markdown +## Creating Custom Shortcuts + +For frequently used complex patterns, create shell aliases: + +```bash +# In your .bashrc or .zshrc +alias rstring-py="rstring --include='*/' --include='*.py' --exclude='test*'" +alias rstring-web="rstring --include='*/' --include='*.js' --include='*.css' --include='*.html'" + +# Usage +rstring-py +rstring-web -C /path/to/project +``` + +### Why This Maximizes Leverage + +1. **Minimal Code**: Removes ~100 lines of preset management +2. **Zero Assumptions**: Conservative default works for everyone +3. **Maximum Power**: Full rsync capabilities always available +4. **User Control**: Shell aliases provide perfect customization +5. **Standard Practice**: Leverages existing Unix conventions +6. **Zero Learning Curve**: Works immediately, aliases are optional + +## Implementation Strategy + +### Phase 1: Remove Preset System +- Delete preset-related code from `cli.py` and `utils.py` +- Remove YAML dependency +- Simplify argument parsing +- Update help text + +### Phase 2: Enhance Documentation +- Add shell alias examples to README +- Include alias suggestions in `--help` output +- Create "Common Patterns" section with copy-pasteable aliases + +### Phase 3: Monitor Usage +- Observe if users request built-in patterns +- Consider adding minimal built-in profiles only if clear demand emerges + +## Risk Analysis + +### Potential Downsides + +1. **Increased Typing**: Users must type longer commands for specific patterns + - **Mitigation**: Shell aliases solve this for recurring needs + +2. **Discovery Problem**: Users might not know common patterns + - **Mitigation**: Documentation with examples + +3. **Team Coordination**: Harder to share patterns + - **Mitigation**: Teams can share shell config snippets + +### Success Metrics + +- **Code Reduction**: Remove ~100 lines of complexity +- **User Feedback**: Monitor for requests for built-in patterns +- **Adoption**: Track usage of documented alias patterns + +## Conclusion + +The preset system represents a classic engineering trade-off between convenience and complexity. While it solves real user problems, it does so at a high leverage cost. The **lean core + shell aliases** approach provides equivalent utility with dramatically lower complexity by leveraging existing Unix conventions. + +### Key Insights + +1. **Leverage is King**: 20x code reduction (100 lines → 5 lines) for equivalent utility +2. **Unix Philosophy**: Leverage existing tools (shell) rather than reinventing +3. **Conservative Defaults**: Avoid assumptions, let users specify intent +4. **Gitignore Integration**: Maximum leverage comes from using existing project standards + +### Final Recommendation + +**Remove the preset system entirely** and replace with: +- Conservative default behavior (`--include=*/` + gitignore) +- Comprehensive documentation of shell alias patterns +- Full preservation of rsync power and customizability + +This approach maximizes leverage while maintaining all the power and flexibility that makes rstring valuable. It trusts users to manage their own workflows using standard Unix tools rather than building a custom configuration system into rstring itself. \ No newline at end of file diff --git a/UX_ANALYSIS.md b/UX_ANALYSIS.md new file mode 100644 index 0000000..6b7320d --- /dev/null +++ b/UX_ANALYSIS.md @@ -0,0 +1,376 @@ +# Rstring UX Analysis: Preset System and User Experience + +## Executive Summary + +The preset system is **architecturally sound but UX-problematic**. While it solves real problems and follows good engineering principles, it suffers from **discovery issues** and **cognitive overhead** that limit adoption. A serious engineer would build a preset system, but would implement it differently to address the UX pain points. + +## The Preset System: Problem Analysis + +### What Problem Does It Solve? + +1. **Rsync Pattern Complexity**: Rsync filter syntax is powerful but cryptic + ```bash + # This is intimidating for most users: + rstring --include=*/ --include=*.py --include=*.js --exclude=node_modules/ --exclude=__pycache__/ --exclude=*.pyc --exclude=.git/ --exclude=* + ``` + +2. **Repetitive Command Construction**: Users need the same patterns repeatedly +3. **Knowledge Sharing**: Teams need consistent file selection patterns +4. **Cognitive Load Reduction**: Abstract complex patterns into memorable names + +### Real-World Pain Points + +**Before presets (hypothetical):** +```bash +# User has to remember/reconstruct this every time: +rstring --include=*/ --include=*.py --include=*.js --include=*.ts --include=*.jsx --include=*.tsx --include=*.css --include=*.html --include=*.md --exclude=node_modules/ --exclude=dist/ --exclude=build/ --exclude=.git/ --exclude=__pycache__/ --exclude=*.pyc --exclude=* +``` + +**With presets:** +```bash +rstring --preset webdev +``` + +**Problem solved**: Reduces 200+ character command to 20 characters. + +## UX Evaluation: The Good + +### 1. **Conceptual Clarity** +The preset concept is immediately understandable: +- "Save this complex command as 'python'" +- "Use the saved 'python' command" +- Mental model aligns with user expectations + +### 2. **Sensible Defaults** +```yaml +common: + is_default: true + args: [extensive exclusion list] +``` +- Works out of the box for most projects +- Reduces initial friction +- Follows "convention over configuration" + +### 3. **Composability** +```bash +rstring --preset python --include=*.md # Extend preset with additional patterns +``` +- Presets don't lock users into rigid behavior +- Can be combined with ad-hoc patterns + +### 4. **Team Sharing** +```bash +rstring --save-preset team-python --include=src/ --include=*.py --exclude=test* +# Share ~/.rstring.yaml with team +``` +- Enables consistent patterns across teams +- Version-controllable configuration + +## UX Evaluation: The Painful + +### 1. **Discovery Problem** (Critical) + +**How does a new user discover what presets exist?** +```bash +$ rstring --help +# No mention of available presets in help output + +$ rstring --list-presets +Saved presets: + * common: --exclude=.git --exclude=__pycache__/* [... 50 more args] + everything: (no args) + pythonx: --include=*/ --include=*.py --exclude=* +``` + +**Problems:** +- Help doesn't show preset examples +- Preset list is overwhelming (50+ args for 'common') +- No description of what each preset is for +- No examples of usage + +### 2. **Cognitive Overhead** (Major) + +**Users must learn a meta-system before using the tool:** +1. Understand that presets exist +2. Learn preset commands (`--save-preset`, `--list-presets`, etc.) +3. Understand preset composition with other args +4. Remember preset names + +**This violates the "immediate utility" principle.** + +### 3. **Naming Confusion** (Minor but Real) + +```bash +$ rstring --list-presets + * common: [50 args] + everything: (no args) + pythonx: [3 args] +``` + +**Questions users have:** +- Why is "everything" empty but "common" has 50 exclusions? +- What's the difference between "python" and "pythonx"? +- What does "common" actually include/exclude? + +### 4. **Preset Management Complexity** (Minor) + +```bash +rstring --save-preset mypreset --include=*.py +rstring --delete-preset mypreset +rstring --set-default-preset mypreset +``` + +**Three different commands for preset management adds cognitive load.** + +## Alternative UX Approaches Analysis + +### Approach 1: No Presets (Simplest) +```bash +rstring --include=*.py --exclude=test* +``` + +**Pros:** +- Zero learning curve +- Immediate utility +- No hidden complexity + +**Cons:** +- Repetitive for complex patterns +- No knowledge sharing +- High cognitive load for complex filters + +**Verdict:** Too simplistic for real-world use + +### Approach 2: Smart Defaults Only +```bash +rstring # Uses intelligent defaults based on project detection +rstring --include=*.py # Override defaults +``` + +**Pros:** +- Zero configuration +- Immediate utility +- No preset management + +**Cons:** +- Magic behavior (hard to predict) +- Limited customization +- No team sharing + +**Verdict:** Good for 80% of cases, insufficient for power users + +### Approach 3: File-Type Shortcuts +```bash +rstring --python # Equivalent to --include=*/ --include=*.py --exclude=* +rstring --web # Equivalent to web development patterns +rstring --docs # Equivalent to documentation patterns +``` + +**Pros:** +- Self-documenting +- No preset management +- Immediate discovery + +**Cons:** +- Limited flexibility +- Hard-coded assumptions +- No customization + +**Verdict:** Better UX but less powerful + +### Approach 4: Improved Preset System (Recommended) + +**Enhanced preset discovery:** +```bash +$ rstring --help +Common presets: + --preset python # Python projects (.py files, exclude tests/cache) + --preset web # Web projects (.js/.css/.html, exclude node_modules) + --preset docs # Documentation (.md/.rst/.txt files) + +$ rstring --presets # Show detailed preset descriptions +``` + +**Enhanced preset creation:** +```bash +rstring --save-preset python "Python development files" --include=*.py --exclude=test* +``` + +**Enhanced preset listing:** +```bash +$ rstring --list-presets +Available presets: + * common - General purpose (excludes build/cache dirs) + python - Python development files + web - Web development files + docs - Documentation files +``` + +## Serious Engineer Assessment + +### Would a Serious Engineer Build a Preset System? + +**Yes, but differently.** The core concept is sound: + +1. **Real Problem**: Rsync patterns are complex and repetitive +2. **Good Abstraction**: Named patterns reduce cognitive load +3. **Composable**: Presets + ad-hoc patterns work well together +4. **Shareable**: Teams need consistent patterns + +### What Would They Do Differently? + +#### 1. **Discovery-First Design** +```bash +$ rstring --help +# Show preset examples prominently in help + +$ rstring --preset +# Interactive preset selection with descriptions +``` + +#### 2. **Self-Documenting Presets** +```yaml +presets: + python: + description: "Python development files (.py, exclude tests/cache)" + args: [--include=*.py, --exclude=test*] + examples: + - "rstring --preset python" + - "rstring --preset python --include=*.md" +``` + +#### 3. **Simplified Management** +```bash +# Instead of --save-preset, --delete-preset, --set-default-preset +rstring preset save python --include=*.py +rstring preset delete python +rstring preset default python +rstring preset list +``` + +#### 4. **Better Defaults** +```bash +# Auto-detect project type and suggest presets +$ rstring +Detected Python project. Suggested preset: --preset python +Using default preset 'common'. Use --preset python for Python-specific filtering. +``` + +## Broader UX Analysis + +### What Works Well + +#### 1. **Immediate Utility** +```bash +rstring # Works immediately with sensible defaults +``` + +#### 2. **Progressive Disclosure** +- Basic usage is simple +- Advanced features are discoverable +- Power users can access full rsync capabilities + +#### 3. **Composability** +```bash +rstring --preset python --include=*.md --summary +``` +- Features combine predictably +- No feature conflicts + +#### 4. **Familiar Patterns** +- `--help` for help +- `--preset` follows CLI conventions +- Rsync patterns for power users + +### What's Problematic + +#### 1. **Steep Learning Curve for Power** +To use rstring effectively, users must learn: +- Rsync include/exclude syntax +- Preset system +- Interactive mode +- Various output options + +#### 2. **Error Messages** +```bash +$ rstring --include=*.py /nonexistent +Error: Directory '/nonexistent' does not exist. +``` +**Good error, but could be more helpful:** +```bash +Error: Directory '/nonexistent' does not exist. +Tip: Use 'rstring -C /path/to/project' to specify a different directory. +``` + +#### 3. **Discoverability** +- No built-in examples +- No guided tour for new users +- Advanced features are hidden + +## Recommendations for UX Improvement + +### High-Impact, Low-Effort + +1. **Improve help output:** + ```bash + $ rstring --help + # Add preset examples and common patterns + ``` + +2. **Better preset descriptions:** + ```bash + $ rstring --list-presets + # Show what each preset is for, not just the args + ``` + +3. **Suggest presets:** + ```bash + $ rstring --include=*.py + # Tip: Use '--preset python' for Python-specific patterns + ``` + +### Medium-Impact, Medium-Effort + +1. **Interactive preset creation:** + ```bash + $ rstring --create-preset + # Guided preset creation wizard + ``` + +2. **Project-type detection:** + ```bash + $ rstring + # Auto-suggest appropriate presets based on project files + ``` + +3. **Better error messages with suggestions** + +### High-Impact, High-Effort + +1. **Complete preset system redesign** with discovery-first approach +2. **Interactive mode improvements** with better UX +3. **Web-based preset sharing** community + +## Conclusion + +The preset system **solves real problems** and follows **good engineering principles**, but suffers from **UX execution issues**. The core concept is sound—a serious engineer would build a preset system—but would prioritize **discovery and usability** over **feature completeness**. + +### Key Insights + +1. **The problem is real**: Rsync patterns are too complex for casual use +2. **The solution is sound**: Named presets reduce cognitive load +3. **The execution is flawed**: Discovery and usability issues limit adoption +4. **The fix is achievable**: Better help, descriptions, and guidance + +The preset system represents **good engineering with poor UX design**. It's architecturally correct but user-hostile. A serious engineer would recognize this and prioritize the UX improvements that make the powerful system actually usable. + +### UX Scorecard +- **Conceptual clarity**: ✅ Good +- **Immediate utility**: ✅ Good (with defaults) +- **Progressive disclosure**: ⚠️ Needs work +- **Discoverability**: ❌ Poor +- **Error handling**: ⚠️ Adequate +- **Learning curve**: ❌ Too steep +- **Power user satisfaction**: ✅ Good + +**Overall**: Good foundation, needs UX polish to reach its potential. \ No newline at end of file From 53394834ab01868ea6f1b5ce2411c47b74ae1024 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:42:03 -0500 Subject: [PATCH 08/12] feat: implement lean core approach - remove preset system completely --- README.md | 85 ++++++++--- rstring/cli.py | 95 ++++-------- rstring/default_presets.yaml | 286 ----------------------------------- rstring/utils.py | 77 ++-------- setup.py | 6 +- tests/test_rstring.py | 182 ++++++++++------------ 6 files changed, 189 insertions(+), 542 deletions(-) delete mode 100644 rstring/default_presets.yaml diff --git a/README.md b/README.md index fe5b108..e36e833 100644 --- a/README.md +++ b/README.md @@ -51,18 +51,19 @@ 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 /path/to/project # Analyze a specific directory -rstring -C /path/to/project --preset my_preset # Change directory with preset +rstring -C /path/to/project # Change directory before processing ``` Custom filtering: ```bash -rstring --include=*/ --include=*.py --exclude=* # traverse all dirs, include .py files, exclude everything else +rstring --include='*.py' # Only Python files +rstring --include='*/' --include='*.js' --exclude='test*' # Complex patterns ``` Get help: @@ -70,11 +71,6 @@ Get help: rstring --help ``` -Use a specific preset: -```bash -rstring --preset my_preset -``` - Get a tree view of selected files: ```bash rstring --summary @@ -82,12 +78,44 @@ rstring --summary ## Advanced Usage -### Custom Presets +### Custom Filtering + +Rstring uses rsync's powerful include/exclude patterns: + +```bash +# 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/'" -Create a new preset: +# 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 --save-preset python --include=*/ --include=*.py --exclude=* # save it -rstring --preset python # use it +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 @@ -97,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: @@ -105,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 @@ -116,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**: ``` @@ -135,16 +162,40 @@ 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) diff --git a/rstring/cli.py b/rstring/cli.py index 2d3eaf8..2ed9ff4 100644 --- a/rstring/cli.py +++ b/rstring/cli.py @@ -3,9 +3,9 @@ import os 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 @@ -50,17 +50,33 @@ def parse_target_directory(args): 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.") 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") @@ -74,28 +90,6 @@ 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(): - args_str = ' '.join(preset.get('args', [])) if preset.get('args') else '(no args)' - print(f" {'*' if preset.get('is_default', False) else ' '} {name}: {args_str}") - 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.") - else: - print(f"Preset '{args.delete_preset}' not found.") - return - - if args.set_default_preset: - set_default_preset(presets, args.set_default_preset) - return - # Parse target directory from -C flag or positional args try: if args.directory: @@ -112,24 +106,11 @@ def main(): print(f"Error: Directory '{target_dir}' does not exist.") return - # Handle presets - preset_name = args.preset or get_default_preset(presets) if not rsync_args_base else None - if preset_name: - preset = presets.get(preset_name) - if preset: - rsync_args = preset['args'] + rsync_args_base - else: - print(f"Error: Preset '{preset_name}' not found.") - return - else: + # Use provided patterns or conservative default + if rsync_args_base: rsync_args = rsync_args_base - - 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 + else: + rsync_args = get_default_patterns() # Handle gitignore in target directory if args.use_gitignore: @@ -194,24 +175,8 @@ def main(): 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" target_info = f" from {target_dir}" if target_dir != original_cwd else "" - - 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}{target_info} using preset '{preset_name}' with modified rsync options: {' '.join(rsync_args)}") - else: - print(f"{action}{target_info} using preset '{preset_name}' modified by .gitignore") - else: - print(f"{action}{target_info} using preset '{preset_name}'") - else: - if 'gitignore_patterns' in locals(): - print( - f"{action}{target_info} using custom rsync options modified by .gitignore: {' '.join(rsync_args).replace(' '.join(gitignore_patterns), '')}") - else: - print(f"{action}{target_info} using custom rsync options: {' '.join(rsync_args)}") + 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 9693267..0000000 --- a/rstring/default_presets.yaml +++ /dev/null @@ -1,286 +0,0 @@ -everything: - is_default: false - args: - -common: - is_default: true - args: - # Exclude common build and cache directories - - --exclude=.git - - --exclude=__pycache__/* - - --exclude=*.py[cod] - - --exclude=*$py.class - - --exclude=*.so - - --exclude=.Python - - --exclude=build/* - - --exclude=develop-eggs/* - - --exclude=dist/* - - --exclude=downloads/* - - --exclude=eggs/* - - --exclude=.eggs/* - - --exclude=lib/* - - --exclude=lib64/* - - --exclude=parts/* - - --exclude=sdist/* - - --exclude=var/* - - --exclude=wheels/* - - --exclude=pip-wheel-metadata/* - - --exclude=share/python-wheels/* - - --exclude=*.egg-info/* - - --exclude=.installed.cfg - - --exclude=*.egg - - --exclude=MANIFEST - - # PyInstaller - - --exclude=*.manifest - - --exclude=*.spec - - # Installer logs - - --exclude=pip-log.txt - - --exclude=pip-delete-this-directory.txt - - # Unit test / coverage reports - - --exclude=htmlcov/* - - --exclude=.tox/* - - --exclude=.nox/* - - --exclude=.coverage - - --exclude=.coverage.* - - --exclude=.cache - - --exclude=nosetests.xml - - --exclude=coverage.xml - - --exclude=*.cover - - --exclude=*.py,cover - - --exclude=.hypothesis/* - - --exclude=.pytest_cache/* - - # Environments - - --exclude=.env - - --exclude=.venv - - --exclude=env/* - - --exclude=venv/* - - --exclude=ENV/* - - --exclude=env.bak/* - - --exclude=venv.bak/* - - # mypy - - --exclude=.mypy_cache/* - - --exclude=.dmypy.json - - --exclude=dmypy.json - - # Pyre type checker - - --exclude=.pyre/* - - # macOS - - --exclude=.DS_Store - - --exclude=.DS_Store? - - --exclude=._* - - --exclude=.Spotlight-V100 - - --exclude=.Trashes - - --exclude=ehthumbs.db - - --exclude=Thumbs.db - - # IDEs - - --exclude=.idea/* - - # Common patterns - - --exclude=.*/ - - --exclude=*.log - - --exclude=*.bak - - --exclude=*.tmp - - --exclude=*.temp - - --exclude=*.swp - - --exclude=*.swo - - --exclude=*~ - - --exclude=Thumbs.db - - --exclude=.DS_Store - - --exclude=.git - - --exclude=.svn - - --exclude=.hg - - --exclude=.vscode - - --exclude=.idea - - --exclude=*.sublime-* - - --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 - - # Node.js - - --exclude=node_modules - - --exclude=bower_components - - --exclude=dist - - --exclude=build - - --exclude=.next - - --exclude=.nuxt - - --exclude=*.min.js - - --exclude=*.min.css - - # Java - - --exclude=vendor/ - - --exclude=*.class - - --exclude=*.jar - - --exclude=*.war - - --exclude=*.ear - - --exclude=target/ - - # C/C++ - - --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 - - # Secrets and keys - - --exclude=*.env - - --exclude=.env* - - --exclude=*.pem - - --exclude=*.key - - --exclude=*_rsa - - --exclude=*_dsa - - --exclude=*.crt - - --exclude=*.cer - - # Binary 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 directories first - - --include=*/ - - # Include common source 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 configuration files - - --include=*.ini - - --include=*.cfg - - --include=*.conf - - --include=*.toml - - --include=*.yaml - - --include=*.yml - - --include=*.json - - --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 - - --include=*.md - - --include=*.rst - - --include=*.txt - - # Include build files - - --include=Dockerfile - - --include=*.dockerfile - - --include=Makefile - - --include=*.mk - - --include=*.gradle - - --include=*.plist - - # Exclude everything else - - --exclude=* - -# Add more presets as needed, for example: - -docs-only: - is_default: false - args: - # Include directories - - --include=*/ - - # Include documentation files - - --include=*.md - - --include=*.rst - - --include=*.txt - - --include=*.adoc - - --include=*.asciidoc - - # Include configuration files that might contain documentation - - --include=*.yaml - - --include=*.yml - - --include=*.json - - --include=*.toml - - # Prune empty directories - - --prune-empty-dirs - - # Exclude everything by default - - --exclude=* diff --git a/rstring/utils.py b/rstring/utils.py index 31b9116..e3519b6 100644 --- a/rstring/utils.py +++ b/rstring/utils.py @@ -5,64 +5,11 @@ import shlex import subprocess -import yaml - 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 [] @@ -203,14 +150,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 03b9c18..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,7 +36,6 @@ }, install_requires=[ "colorama>=0.4.6", - "pyyaml>=6.0.1", ], extras_require={ 'dev': [ @@ -44,8 +43,5 @@ 'hypothesis>=6.108.5', ] }, - package_data={ - "rstring": ["default_presets.yaml"], - }, include_package_data=True, ) diff --git a/tests/test_rstring.py b/tests/test_rstring.py index c5cccbe..93e055a 100644 --- a/tests/test_rstring.py +++ b/tests/test_rstring.py @@ -1,10 +1,7 @@ import subprocess from unittest.mock import patch, MagicMock, mock_open - -import pytest -import yaml import tempfile -import shutil +import pytest import os import sys @@ -13,67 +10,19 @@ from rstring import utils, cli from rstring.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, is_binary + parse_gitignore, is_binary ) from rstring.cli import parse_target_directory, main -@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() - - -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 - - def test_check_rsync(): with patch('subprocess.run') as mock_run: mock_run.return_value = MagicMock(returncode=0) 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" @@ -182,22 +131,6 @@ 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)) - - 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.gather_code', return_value=mock_gathered_code): - with patch('rstring.cli.copy_to_clipboard') as mock_copy: - cli.main() - mock_copy.assert_called_once() - mock_copy.assert_called_with(mock_gathered_code) - - def test_parse_target_directory(): """Test the parse_target_directory function.""" # Test -C flag @@ -206,74 +139,111 @@ def test_parse_target_directory(): assert remaining == ['--include=*.py'] # Test --directory flag - target_dir, remaining = parse_target_directory(['--directory', '/tmp', '--include=*.py']) - assert target_dir == '/tmp' - assert remaining == ['--include=*.py'] + target_dir, remaining = parse_target_directory(['--directory', '/home', '--include=*.js']) + assert target_dir == '/home' + assert remaining == ['--include=*.js'] - # Test --directory= format - target_dir, remaining = parse_target_directory(['--directory=/tmp', '--include=*.py']) - assert target_dir == '/tmp' - assert remaining == ['--include=*.py'] - - # Test positional directory (when it exists) + # Test positional argument with tempfile.TemporaryDirectory() as temp_dir: target_dir, remaining = parse_target_directory([temp_dir, '--include=*.py']) - assert target_dir == os.path.abspath(temp_dir) + assert target_dir == temp_dir assert remaining == ['--include=*.py'] - # Test no directory specified + # Test default to current directory target_dir, remaining = parse_target_directory(['--include=*.py']) assert target_dir == os.path.abspath('.') assert remaining == ['--include=*.py'] - # Test error case - with pytest.raises(ValueError): - parse_target_directory(['-C']) +def test_main_with_default_patterns(): + """Test main function with default patterns (no user args).""" + mock_gathered_code = 'print("Hello")\n' * 26 + 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('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.load_presets') @patch('rstring.cli.check_rsync') -def test_main_with_target_directory(mock_check_rsync, mock_load_presets, mock_get_tree_string, - mock_copy_to_clipboard, mock_gather_code, mock_run_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_load_presets.return_value = {} 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("hello")') + f.write('print("test")') - # Test -C flag - with patch('sys.argv', ['rstring', '-C', temp_dir, '--include=*.py', '--no-clipboard']): + with patch('sys.argv', ['rstring', '-C', temp_dir, '--include=*.py']): with patch('os.chdir') as mock_chdir: - main() - # Should change to target dir and then back to original - assert mock_chdir.call_count == 2 - mock_chdir.assert_any_call(temp_dir) + cli.main() - # Test positional argument - with patch('sys.argv', ['rstring', temp_dir, '--include=*.py', '--no-clipboard']): - with patch('os.chdir') as mock_chdir: - main() - # Should change to target dir and then back to original + # Should change to target directory and back assert mock_chdir.call_count == 2 - mock_chdir.assert_any_call(temp_dir) + 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('rstring.cli.load_presets', return_value={}): - with patch('sys.argv', ['rstring', '-C', '/nonexistent', '--include=*.py']): - with patch('builtins.print') as mock_print: - main() - mock_print.assert_called_with("Error: Directory '/nonexistent' does not exist.") + 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.") + + +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=*/'] From df278258934bf46839a5def37e8f9aca19e4f487 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Sat, 24 May 2025 22:43:14 -0500 Subject: [PATCH 09/12] chore: remove analysis docs and defunct files from git tracking --- ANALYSIS.md | 146 --------- CONTRIBUTING.md | 53 ---- DETERMINE_TARGET_DIRECTORY_ANALYSIS.md | 272 ---------------- POWER_ANALYSIS.md | 273 ---------------- PRESET_SYSTEM_ANALYSIS.md | 311 ------------------- UX_ANALYSIS.md | 376 ----------------------- defunct/stringify-old.py | 49 --- defunct/stringify.py.before | 102 ------ defunct/stringify.py.lastbeforersync | 128 -------- defunct/stringify.py.singlefile | 279 ----------------- defunct/test_stringify.py.before | 121 -------- defunct/test_stringify.py.disabled | 154 ---------- defunct/test_stringify_rsync.py.disabled | 72 ----- nothin/emptyfile.txt | 0 nothin/nonemptyfile.txt | 1 - system_limits_audit.md | 90 ------ tests/empty/anything | 0 17 files changed, 2427 deletions(-) delete mode 100644 ANALYSIS.md delete mode 100644 CONTRIBUTING.md delete mode 100644 DETERMINE_TARGET_DIRECTORY_ANALYSIS.md delete mode 100644 POWER_ANALYSIS.md delete mode 100644 PRESET_SYSTEM_ANALYSIS.md delete mode 100644 UX_ANALYSIS.md delete mode 100755 defunct/stringify-old.py delete mode 100755 defunct/stringify.py.before delete mode 100755 defunct/stringify.py.lastbeforersync delete mode 100755 defunct/stringify.py.singlefile delete mode 100755 defunct/test_stringify.py.before delete mode 100644 defunct/test_stringify.py.disabled delete mode 100644 defunct/test_stringify_rsync.py.disabled delete mode 100644 nothin/emptyfile.txt delete mode 100644 nothin/nonemptyfile.txt delete mode 100644 system_limits_audit.md delete mode 100644 tests/empty/anything diff --git a/ANALYSIS.md b/ANALYSIS.md deleted file mode 100644 index 7bddb51..0000000 --- a/ANALYSIS.md +++ /dev/null @@ -1,146 +0,0 @@ -# Rstring: Strategic Engineering Analysis - -## Executive Summary - -Rstring is a developer tool that leverages rsync's powerful file filtering capabilities to efficiently gather and stringify code from projects, primarily for feeding to AI programming assistants. The core insight—using rsync as the file selection engine—is architecturally brilliant, providing enterprise-grade filtering with minimal code complexity. - -## 1. What It Is - -Rstring is a command-line utility that: -- Uses rsync's include/exclude patterns to select files from codebases -- Concatenates selected files into a single string with clear delimiters -- Automatically copies output to clipboard for AI assistant consumption -- Provides preset management for common file selection patterns -- Integrates with git to respect .gitignore patterns -- Offers interactive mode for iterative file selection refinement - -The tool addresses a specific pain point: efficiently preparing code context for AI programming assistants without manually copying files or writing complex filtering logic. - -## 2. Architectural Genius and Limitations - -### Genius -1. **Rsync as the Core Engine**: Using rsync for file selection is architecturally brilliant. Rsync's filter system is mature, battle-tested, and incredibly powerful. This single decision provides: - - Complex pattern matching (wildcards, directory traversal, exclusions) - - High performance on large codebases - - Familiar syntax for Unix users - - Zero need to reinvent file filtering logic - -2. **Preset System**: The YAML-based preset system with defaults is well-designed: - - Sensible defaults that work for most projects - - Easy customization and sharing - - Default preset concept reduces cognitive load - -3. **Composability**: The tool composes well with other Unix tools and workflows, following Unix philosophy. - -### Limitations -1. **Rsync Dependency**: Requires rsync installation, which may not be available on all systems (particularly Windows without WSL). - -2. **Complex Error Handling**: Rsync errors can be cryptic for non-technical users. - -3. **Limited Cross-Platform Support**: While functional, the clipboard integration and rsync dependency make it less seamless on Windows. - -## 3. UX Analysis - -### Power -- **Immediate Utility**: Solves a real problem developers face daily -- **Flexible Filtering**: Rsync patterns provide enormous flexibility -- **Clipboard Integration**: Seamless workflow integration -- **Tree Visualization**: Helps users understand what files are selected -- **Interactive Mode**: Allows iterative refinement - -### Flaws -- **Learning Curve**: Rsync pattern syntax is not intuitive for casual users -- **Error Messages**: Cryptic rsync errors don't guide users effectively -- **Discovery**: Hard to discover optimal patterns without rsync knowledge -- **Feedback Loop**: Limited preview capabilities before full execution - -## 4. Analysis of Unshipped Changes (other-targets branch) - -### The Good Changes - -1. **Target Directory Support**: The `determine_target_directory()` function attempts to support specifying different source directories, which is valuable for working with multiple projects. - -2. **Improved Tree Visualization**: The tree.py refactor improves path handling and makes the tree generation more robust. - -3. **Better Path Handling**: More careful path normalization and absolute path handling. - -4. **Enhanced Testing**: Addition of property-based testing with Hypothesis shows good engineering practices. - -5. **Git Integration**: The git.py module for filtering ignored files is a sensible addition. - -### The Problematic Changes - -1. **Incomplete Implementation**: The `determine_target_directory()` function is clearly unfinished: - - Contains debug print statements - - Complex logic that's hard to follow - - Unclear error handling - - The "TODO: fix this hack" comment in `gather_code()` indicates rushed implementation - -2. **Breaking Changes**: The function signature changes break existing functionality (evident from the TypeError in preset listing). - -3. **Overengineering**: The target directory detection logic is overly complex for what should be a simple feature. - -4. **Inconsistent State**: The branch contains both working improvements and broken functionality. - -## 5. Strategic Recommendations - -### High-Leverage Improvements - -1. **Simplify Target Directory Support**: - - Instead of complex rsync output parsing, simply accept a `--directory` flag - - Use `os.chdir()` or pass working directory to subprocess calls - - Much simpler and more predictable - -2. **Improve Error Handling and UX**: - - Wrap rsync errors with user-friendly messages - - Add `--dry-run` mode to preview file selection - - Better validation of rsync patterns before execution - -3. **Enhanced Discovery**: - - Add `--suggest` mode that analyzes project structure and suggests appropriate patterns - - Include common preset examples in help text - - Better documentation of rsync pattern syntax - -4. **Performance Optimizations**: - - Cache rsync results for interactive mode - - Add progress indicators for large codebases - - Implement file size limits with warnings - -### Architectural Decisions - -1. **Keep Rsync Core**: The rsync dependency is the tool's greatest strength. Don't replace it. - -2. **Preset Evolution**: Consider preset versioning and community sharing mechanisms. - -3. **Plugin Architecture**: Consider allowing custom output formatters while keeping the core simple. - -### Development Section Assessment - -The proposed README development section is **not recommended** for a tool of this caliber. High-end OSS tools typically: -- Have contributing guidelines in CONTRIBUTING.md -- Assume developers can figure out basic setup -- Focus documentation on usage, not development setup -- Keep README focused on user value proposition - -The development section adds cognitive overhead without proportional value for the target audience. - -## 6. Competitive Positioning - -Rstring occupies a unique niche: -- **vs. find/grep**: More powerful filtering, better output format -- **vs. IDE plugins**: Language-agnostic, composable with any workflow -- **vs. custom scripts**: Standardized, maintained, preset system - -The tool's strength is its focused scope and architectural leverage of existing tools. - -## 7. Conclusion - -Rstring demonstrates excellent architectural thinking by leveraging rsync's mature filtering capabilities. The core concept is sound and the implementation is largely well-executed. The unshipped changes show both good engineering instincts (testing, git integration) and problematic overengineering (target directory detection). - -The path forward should focus on: -1. Fixing the broken functionality in other-targets -2. Simplifying the target directory feature -3. Improving user experience around error handling and discovery -4. Maintaining the tool's focused scope and architectural simplicity - -The tool has strong potential for adoption in AI-assisted development workflows, provided the UX rough edges are smoothed and the core reliability is maintained. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index ef879a8..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,53 +0,0 @@ -# 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/DETERMINE_TARGET_DIRECTORY_ANALYSIS.md b/DETERMINE_TARGET_DIRECTORY_ANALYSIS.md deleted file mode 100644 index 1454c42..0000000 --- a/DETERMINE_TARGET_DIRECTORY_ANALYSIS.md +++ /dev/null @@ -1,272 +0,0 @@ -# The `determine_target_directory` Approach: A Deep Technical Analysis - -## Executive Summary - -The `determine_target_directory` function represents a classic case of **over-engineering a simple problem into a complex one**. While the implied goal—supporting arbitrary target directories with maximum flexibility—is admirable, the execution path chosen is fundamentally flawed and would not be pursued by a top-tier engineer. This analysis examines why. - -## The Implied Goal: Maximum Power and Flexibility - -The function attempts to solve this problem: **"Given any rsync command with any combination of patterns and target directories, automatically determine the correct working directory and file paths."** - -The implied vision is powerful: -- `rstring --include=*.py /path/to/project` should work seamlessly -- `rstring --include=*.py ../other-project` should work seamlessly -- `rstring --include=*.py .` should work seamlessly -- All without requiring explicit `--directory` flags or manual path specification - -This is a worthy goal that would significantly improve UX. However, the implementation approach is fundamentally problematic. - -## Technical Analysis of the Current Approach - -### The Core Strategy - -The function uses this strategy: -1. Execute rsync twice: once normally, once with `-R` flag -2. Compare outputs to detect if user used relative paths -3. Parse rsync output to extract file paths -4. Use `os.path.commonpath()` to determine target directory -5. Apply complex logic to "trim" the path based on relative vs absolute detection - -### Execution Path Analysis - -#### Path 1: User provides relative path (e.g., `rstring .`) -```python -# rsync -ain --list-only --include=*.py . -# vs -# rsync -ain --list-only --include=*.py . -R -# Outputs are identical, so user_used_relative = True -``` - -#### Path 2: User provides absolute path (e.g., `rstring /path/to/project`) -```python -# rsync -ain --list-only --include=*.py /path/to/project -# Output: "rstring/.gitignore", "rstring/file.py" -# vs -# rsync -ain --list-only --include=*.py /path/to/project -R -# Output: "home/user/path/to/project/.gitignore", "home/user/path/to/project/file.py" -# Outputs differ, so user_used_relative = False -``` - -#### Path 3: Complex relative paths (e.g., `rstring ../other-project`) -This is where the approach completely breaks down. The logic cannot reliably distinguish between: -- User intention vs rsync's path resolution -- Working directory context vs target directory context -- Relative path semantics in different shell environments - -### Critical Flaws in the Approach - -#### 1. **Rsync Output Parsing Fragility** -```python -def parse_rsync_output_line(line): - return line.split()[4] # Assumes exactly 5 space-separated fields -``` - -Rsync output format is: -``` --rw-rw-r-- 7,044 2025/05/24 21:35:27 ANALYSIS.md -``` - -This parsing is fragile because: -- File names with spaces break the split logic -- Different rsync versions may have different output formats -- Symlinks, special files, and permissions can alter the format -- Locale settings can affect date/time formatting - -#### 2. **Double Rsync Execution** -Every call to `determine_target_directory` executes rsync **twice**: -- Performance penalty (especially on large codebases) -- Potential for race conditions if filesystem changes between calls -- Doubled error surface area - -#### 3. **Complex State Logic** -```python -if not user_used_relative and original_relative_path != '.': - target_dir_trimmed = target_dir.rsplit('/', 1)[0] -else: - target_dir_trimmed = target_dir -``` - -This logic attempts to handle multiple cases but creates more edge cases: -- What if `rsplit('/', 1)` returns unexpected results? -- What if the path is already at root? -- What about Windows path separators? -- What about symlinks that change path resolution? - -#### 4. **Fundamental Conceptual Flaw** -The approach tries to **reverse-engineer user intent from rsync output**. This is inherently unreliable because: -- Rsync output is the *result* of path resolution, not the *intent* -- Multiple different user inputs can produce identical rsync outputs -- The function conflates "what rsync sees" with "what the user meant" - -## Complexity Explosion Analysis - -### Current Complexity Factors -1. **Rsync behavior matrix**: 4 combinations (relative/absolute × with/without -R) -2. **Path parsing edge cases**: Spaces, special characters, Unicode -3. **Filesystem edge cases**: Symlinks, mount points, permissions -4. **Platform differences**: Windows vs Unix path handling -5. **Error propagation**: Two rsync calls = 2× error scenarios - -### Projected Complexity Growth -If this approach were pursued seriously, it would require handling: -- **Windows path semantics** (drive letters, UNC paths, backslashes) -- **Symlink resolution** (what if target is a symlink?) -- **Mount point boundaries** (what if paths cross filesystems?) -- **Permission edge cases** (what if rsync can list but not read?) -- **Network paths** (NFS, SMB, etc.) -- **Container environments** (Docker volume mounts, etc.) -- **Rsync version differences** (output format variations) -- **Locale variations** (date formats, character encodings) - -Each additional edge case multiplies the testing matrix exponentially. - -## What Top-Tier Engineers Would Consider - -### Option 1: Explicit Directory Flag (Recommended) -```bash -rstring --directory /path/to/project --include=*.py -rstring -C /path/to/project --include=*.py # git-style -``` - -**Pros:** -- Crystal clear semantics -- Zero ambiguity -- Trivial implementation -- Composable with other tools -- Follows established CLI patterns (git -C, make -C, etc.) - -**Cons:** -- Slightly more verbose -- Requires user to specify directory explicitly - -### Option 2: Positional Argument with Clear Semantics -```bash -rstring /path/to/project --include=*.py -``` - -**Implementation:** -```python -def parse_args(): - if len(unknown_args) > 0 and not unknown_args[0].startswith('-'): - target_dir = unknown_args[0] - rsync_args = unknown_args[1:] + preset_args - else: - target_dir = '.' - rsync_args = unknown_args + preset_args -``` - -**Pros:** -- Clean UX -- Simple implementation -- Clear semantics -- No rsync output parsing - -**Cons:** -- Potential ambiguity with rsync patterns -- Requires careful argument parsing - -### Option 3: Working Directory Context (Current Behavior) -```bash -cd /path/to/project && rstring --include=*.py -``` - -**Pros:** -- Follows Unix philosophy -- Zero implementation complexity -- Composable with shell workflows -- No ambiguity - -**Cons:** -- Requires user to change directories -- Less convenient for one-off commands - -### Option 4: Smart Detection with Fallback -```python -def determine_target_directory_simple(args): - # Look for obvious directory arguments - for arg in args: - if not arg.startswith('-') and os.path.isdir(arg): - return os.path.abspath(arg) - return os.getcwd() -``` - -**Pros:** -- Handles 90% of use cases simply -- Clear fallback behavior -- No rsync output parsing -- Fast execution - -**Cons:** -- May not handle all edge cases -- Could misinterpret rsync patterns as directories - -## Engineering Judgment: Would a Serious Engineer Pursue This? - -**Absolutely not.** Here's why: - -### 1. **Complexity-to-Value Ratio** -The current approach has **exponential complexity growth** for **linear value increase**. The 80/20 rule strongly favors simpler solutions. - -### 2. **Reliability Concerns** -The approach introduces multiple failure modes: -- Rsync output parsing failures -- Path resolution edge cases -- Platform-specific behaviors -- Race conditions - -### 3. **Maintenance Burden** -Every rsync version update, platform addition, or edge case discovery requires revisiting this complex logic. - -### 4. **Debugging Nightmare** -When this function fails (and it will), debugging requires: -- Understanding rsync internals -- Reproducing exact filesystem states -- Analyzing complex path resolution logic -- Considering platform-specific behaviors - -### 5. **Violates KISS Principle** -The problem has simple solutions that are more reliable, more maintainable, and more predictable. - -## Recommended Path Forward - -A top-tier engineer would: - -1. **Abandon the current approach entirely** -2. **Implement Option 1 (explicit directory flag)** as the primary interface -3. **Add Option 2 (positional argument)** as syntactic sugar -4. **Keep Option 3 (working directory)** as the default behavior -5. **Document the behavior clearly** with examples - -### Implementation Sketch -```python -def parse_target_directory(args): - """Simple, reliable target directory detection.""" - parser = argparse.ArgumentParser() - parser.add_argument('-C', '--directory', help='Change to directory before processing') - parser.add_argument('target', nargs='?', help='Target directory (optional)') - - parsed, remaining = parser.parse_known_args(args) - - if parsed.directory: - return os.path.abspath(parsed.directory), remaining - elif parsed.target and os.path.isdir(parsed.target): - return os.path.abspath(parsed.target), remaining - else: - return os.getcwd(), args -``` - -This approach is: -- **Simple**: ~10 lines vs ~50 lines -- **Reliable**: No rsync output parsing -- **Fast**: No double execution -- **Maintainable**: Clear logic flow -- **Extensible**: Easy to add new patterns -- **Debuggable**: Obvious failure modes - -## Conclusion - -The `determine_target_directory` approach represents a classic engineering anti-pattern: **solving a simple problem with complex machinery**. While the goal of maximum flexibility is admirable, the implementation path chosen creates more problems than it solves. - -A serious engineer would recognize this as a **complexity trap** and choose one of the simpler, more reliable alternatives. The current approach should be abandoned in favor of explicit, predictable semantics that users can understand and rely on. - -The lesson here is that **engineering judgment** often means choosing the boring, simple solution over the clever, complex one. In this case, the boring solution is objectively superior in every meaningful metric: reliability, maintainability, performance, and user experience. \ No newline at end of file diff --git a/POWER_ANALYSIS.md b/POWER_ANALYSIS.md deleted file mode 100644 index d87b0d0..0000000 --- a/POWER_ANALYSIS.md +++ /dev/null @@ -1,273 +0,0 @@ -# Power and Composability Analysis: Simple vs Complex Target Directory Approaches - -## Executive Summary - -**Yes, the simpler approaches maintain 100% of rstring's power and composability while actually *increasing* leverage in many scenarios.** The complex `determine_target_directory` approach doesn't provide additional power—it just obscures the existing power behind unreliable automation. - -## Power Comparison Matrix - -### Core Rsync Power (Unchanged) -Both approaches provide identical access to rsync's full filtering capabilities: - -```bash -# Complex approach -rstring /path/to/project --include=*/ --include=*.py --exclude=test* --exclude=* - -# Simple approach (-C flag) -rstring -C /path/to/project --include=*/ --include=*.py --exclude=test* --exclude=* - -# Simple approach (positional) -rstring /path/to/project --include=*/ --include=*.py --exclude=test* --exclude=* -``` - -**Result: 100% power retention** - -### Preset System Power (Enhanced) -The simple approaches actually *enhance* preset power: - -```bash -# Complex approach (brittle with paths) -rstring /path/to/other-project --preset python # May break due to path parsing - -# Simple approach (reliable) -rstring -C /path/to/other-project --preset python # Always works -rstring /path/to/other-project --preset python # Always works -``` - -**Result: Power increased through reliability** - -### Composability Analysis - -#### Shell Composability (Massively Enhanced) -```bash -# Complex approach: Limited composability due to path parsing ambiguity -rstring $(find . -name "*.project" -type d | head -1) --include=*.py # Risky - -# Simple approach: Perfect composability -rstring -C $(find . -name "*.project" -type d | head -1) --include=*.py # Safe -find . -name "*.project" -type d | xargs -I {} rstring -C {} --preset python # Powerful -``` - -#### Script Integration (Enhanced) -```bash -#!/bin/bash -# Complex approach: Fragile -for project in /path/to/projects/*; do - rstring "$project" --preset common # May fail on edge cases -done - -# Simple approach: Bulletproof -for project in /path/to/projects/*; do - rstring -C "$project" --preset common # Always works -done -``` - -#### CI/CD Integration (Enhanced) -```yaml -# Complex approach: Unreliable in containers -- run: rstring /workspace/src --include=*.py - -# Simple approach: Reliable everywhere -- run: rstring -C /workspace/src --include=*.py -``` - -## Power Features Comparison - -### 1. Multi-Project Workflows - -**Complex Approach:** -```bash -# Fragile - path parsing may fail -rstring ../project-a --include=*.py -rstring /abs/path/to/project-b --include=*.py -``` - -**Simple Approach:** -```bash -# Bulletproof -rstring -C ../project-a --include=*.py -rstring -C /abs/path/to/project-b --include=*.py - -# Even more powerful - batch processing -for proj in ../project-*; do rstring -C "$proj" --preset common; done -``` - -### 2. Complex Path Scenarios - -**Complex Approach:** -```bash -# These may break due to rsync output parsing: -rstring "/path with spaces/project" --include=*.py -rstring "~/projects/my project" --include=*.py -rstring "/mnt/network/share/project" --include=*.py -``` - -**Simple Approach:** -```bash -# These always work: -rstring -C "/path with spaces/project" --include=*.py -rstring -C "~/projects/my project" --include=*.py -rstring -C "/mnt/network/share/project" --include=*.py -``` - -### 3. Advanced Rsync Patterns - -**Both approaches support identical rsync power:** -```bash -# Complex nested includes/excludes -rstring -C /project --include=src/ --include=src/**/*.py --exclude=src/test* --exclude=* - -# Prune empty directories -rstring -C /project --prune-empty-dirs --include=docs/ --include=*.md --exclude=* - -# Complex globbing -rstring -C /project --include=**/test_*.py --exclude=**/*_old.py -``` - -**Result: Identical power, better reliability** - -## Leverage Analysis - -### Current Leverage Points (Maintained) -1. **Rsync's mature filtering system** ✅ Fully maintained -2. **Preset system for reusability** ✅ Enhanced through reliability -3. **Interactive mode for refinement** ✅ Fully maintained -4. **Git integration** ✅ Fully maintained -5. **Tree visualization** ✅ Fully maintained -6. **Clipboard integration** ✅ Fully maintained - -### New Leverage Points (Added) -1. **Predictable behavior** → Enables automation -2. **Shell composability** → Enables complex workflows -3. **Error predictability** → Enables robust scripting -4. **Platform independence** → Enables cross-platform tools - -## Real-World Power Scenarios - -### Scenario 1: Multi-Repository Analysis -```bash -# Complex approach: Fragile -find ~/projects -name ".git" -type d | while read repo; do - project_dir=$(dirname "$repo") - rstring "$project_dir" --preset common # May fail -done - -# Simple approach: Robust -find ~/projects -name ".git" -type d | while read repo; do - project_dir=$(dirname "$repo") - rstring -C "$project_dir" --preset common # Always works -done -``` - -### Scenario 2: Docker/Container Integration -```dockerfile -# Complex approach: Unreliable in containers -RUN rstring /workspace/src --include=*.py > context.txt - -# Simple approach: Reliable everywhere -RUN rstring -C /workspace/src --include=*.py > context.txt -``` - -### Scenario 3: IDE/Editor Integration -```python -# Complex approach: Hard to integrate reliably -def get_project_context(project_path): - # Risk of path parsing failures - result = subprocess.run(['rstring', project_path, '--preset', 'common']) - -# Simple approach: Easy to integrate -def get_project_context(project_path): - # Guaranteed to work - result = subprocess.run(['rstring', '-C', project_path, '--preset', 'common']) -``` - -## Power Loss Analysis - -**What power does the complex approach provide that simple approaches don't?** - -1. **Automatic path detection** - But this is unreliable and creates more problems than it solves -2. **"Magic" behavior** - But magic that fails is worse than explicit behavior that works - -**Conclusion: The complex approach provides zero additional real power.** - -## Composability Enhancement Examples - -### 1. Pipeline Integration -```bash -# Simple approach enables powerful pipelines -git ls-files --others --ignored --exclude-standard | \ - grep -E '\.(py|js|ts)$' | \ - head -10 | \ - xargs -I {} dirname {} | \ - sort -u | \ - xargs -I {} rstring -C {} --preset common -``` - -### 2. Parallel Processing -```bash -# Simple approach enables safe parallelization -find . -name "*.project" -type d | \ - parallel rstring -C {} --preset common -``` - -### 3. Configuration Management -```bash -# Simple approach enables configuration-driven workflows -while IFS= read -r project_config; do - project_path=$(echo "$project_config" | cut -d: -f1) - preset_name=$(echo "$project_config" | cut -d: -f2) - rstring -C "$project_path" --preset "$preset_name" -done < project_list.txt -``` - -## Implementation Strategy for Maximum Power - -### Recommended Implementation -```python -def parse_target_directory(args): - """Maximum power with maximum reliability.""" - parser = argparse.ArgumentParser() - parser.add_argument('-C', '--directory', help='Change to directory before processing') - parser.add_argument('target', nargs='?', help='Target directory (optional)') - - parsed, remaining = parser.parse_known_args(args) - - if parsed.directory: - return os.path.abspath(parsed.directory), remaining - elif parsed.target and os.path.isdir(parsed.target): - return os.path.abspath(parsed.target), remaining - else: - return os.getcwd(), args -``` - -### Power Features to Add -1. **Multiple target support:** - ```bash - rstring -C /proj1 -C /proj2 --preset common # Process multiple projects - ``` - -2. **Output aggregation:** - ```bash - rstring -C /proj1 --output=proj1.txt --preset common - ``` - -3. **Template support:** - ```bash - rstring -C /project --template=ai-context --preset common - ``` - -## Conclusion - -**The simple approaches provide 100% of the power with significantly enhanced composability and reliability.** The complex `determine_target_directory` approach is a false economy—it promises convenience but delivers fragility. - -### Power Scorecard -- **Rsync filtering power**: Simple ✅ = Complex ✅ -- **Preset system power**: Simple ✅ > Complex ⚠️ (more reliable) -- **Shell composability**: Simple ✅✅ >> Complex ⚠️ (much better) -- **Automation potential**: Simple ✅✅ >> Complex ❌ (much better) -- **Cross-platform reliability**: Simple ✅✅ >> Complex ❌ (much better) -- **Debugging simplicity**: Simple ✅✅ >> Complex ❌ (much better) - -**Result: The simple approach is strictly superior in power, composability, and reliability.** - -The key insight is that **explicit is more powerful than implicit** when the implicit behavior is unreliable. Users get more leverage from predictable tools they can compose reliably than from "smart" tools that sometimes fail in mysterious ways. \ No newline at end of file diff --git a/PRESET_SYSTEM_ANALYSIS.md b/PRESET_SYSTEM_ANALYSIS.md deleted file mode 100644 index 495200b..0000000 --- a/PRESET_SYSTEM_ANALYSIS.md +++ /dev/null @@ -1,311 +0,0 @@ -# Preset System Analysis: A Deep Dive into Leverage, UX, and Engineering Trade-offs - -## Executive Summary - -After extensive analysis of rstring's preset system, we've identified a fundamental tension between **user convenience** and **engineering leverage**. The current user-managed preset system solves real problems but at a high complexity cost. Through systematic evaluation of alternatives, we've converged on a **lean core + shell aliases** approach that maximizes leverage while maintaining full power and customizability. - -## The Original Problem Statement - -### What the Preset System Attempts to Solve - -1. **Rsync Pattern Complexity**: Raw rsync include/exclude syntax is cryptic and error-prone - ```bash - # This is intimidating and hard to get right: - rstring --include=*/ --include=*.py --exclude=test* --exclude=__pycache__/ --exclude=* - ``` - -2. **Repetitive Command Construction**: Users need the same complex patterns repeatedly -3. **Knowledge Sharing**: Teams need consistent file selection patterns -4. **Cognitive Load**: Abstract complex patterns into memorable names - -### Real-World Pain Points - -**Without presets:** -```bash -# User must type this every time for Python projects: -rstring --include=*/ --include=*.py --include=*.md --exclude=test* --exclude=__pycache__/ --exclude=* -``` - -**With presets:** -```bash -rstring --preset python # 20 characters vs 100+ -``` - -## Current System Analysis - -### Architectural Assessment - -**Strengths:** -- ✅ **Conceptually Sound**: Save complex commands as named shortcuts -- ✅ **Rsync Passthrough**: Zero reimplementation of filtering logic -- ✅ **Composable**: Presets + ad-hoc args work together -- ✅ **Team Shareable**: YAML config can be version controlled - -**Critical Weaknesses:** -- ❌ **Discovery Problem**: New users can't find or understand presets -- ❌ **Cognitive Overhead**: Must learn meta-system before using tool -- ❌ **UX Complexity**: Multiple preset management commands -- ❌ **Code Complexity**: YAML parsing, file I/O, CRUD operations - -### Leverage Analysis of Current System - -**Code Complexity**: ~100 lines across multiple files -- YAML file handling -- Preset CRUD operations -- Default preset management -- Error handling for preset operations - -**User Cognitive Load**: High -- Must understand preset concept -- Must learn preset management commands -- Must remember preset names -- Must understand preset composition with other args - -**Utility Provided**: Medium -- Saves typing for complex commands -- Enables team consistency -- Reduces errors in rsync pattern construction - -**Leverage Score**: Medium (Utility/Complexity = Medium/High) - -## Alternative Approaches Evaluated - -### Approach 1: Smart Defaults with Project Detection - -**Concept:** -```python -def get_smart_defaults(directory): - if os.path.exists('requirements.txt'): return ['--include=*/', '--include=*.py'] - if os.path.exists('package.json'): return ['--include=*/', '--include=*.js', '--include=*.ts'] - return ['--include=*/'] # Conservative fallback -``` - -**Leverage Analysis:** -- **Code**: ~30 lines -- **UX**: Zero configuration, immediate utility -- **Problems**: Makes assumptions about user preferences, limited flexibility - -**Verdict**: Higher leverage than current system, but makes too many assumptions - -### Approach 2: Built-in, Non-Editable Profiles - -**Concept:** -```bash -rstring --profile python # Built-in patterns for Python -rstring --profile web # Built-in patterns for web development -``` - -**Leverage Analysis:** -- **Code**: ~20 lines (simple dictionary lookup) -- **UX**: Easy discovery, no management overhead -- **Problems**: Users can't customize profiles, may not match needs - -**Verdict**: Good leverage, but limited user control - -### Approach 3: Conservative Default + Shell Aliases - -**Concept:** -```bash -# rstring provides simple, unopinionated default -rstring # Uses --include=*/ + gitignore filtering - -# Users create their own aliases for complex needs -alias rstring-py="rstring --include=*/ --include=*.py --exclude=test*" -``` - -**Leverage Analysis:** -- **Code**: ~5 lines (minimal default logic) -- **UX**: Immediate utility + user-controlled customization -- **Power**: Full rsync capabilities + user environment leverage - -**Verdict**: Highest leverage approach - -## The Gitignore Integration Insight - -### What Gitignore Handles Well -- ✅ **Build artifacts**: `__pycache__/`, `dist/`, `build/` -- ✅ **Dependencies**: `node_modules/`, `venv/` -- ✅ **IDE files**: `.idea/`, `.vscode/` -- ✅ **OS files**: `.DS_Store`, `Thumbs.db` - -### Where Gitignore Falls Short -- ❌ **Inclusion patterns**: Gitignore is exclusion-only -- ❌ **File type selection**: Can't express "only Python files" -- ❌ **Context-specific filtering**: Can't distinguish between "source for AI" vs "all project files" - -### Key Insight: Universal Exclusions Are Redundant - -Analysis of real gitignore files shows that proposed "universal exclusions" like `__pycache__/` and `node_modules/` are already covered by well-maintained gitignore files. Adding our own exclusions would create redundancy and maintenance overhead. - -**Recommendation**: Rely 100% on gitignore for exclusions, focus on inclusion patterns. - -## The Leverage Deep Dive - -### Defining Leverage in This Context - -**Leverage = Utility Provided / (Code Complexity + User Cognitive Load)** - -### Current Preset System Leverage Breakdown - -**Utility Provided:** -- Saves typing: High value for complex patterns -- Reduces errors: Medium value (rsync patterns are tricky) -- Enables sharing: Low-medium value (team coordination) -- Reduces learning: Negative value (adds learning overhead) - -**Code Complexity:** -- YAML handling: ~20 lines -- File I/O operations: ~15 lines -- Preset CRUD: ~30 lines -- CLI integration: ~20 lines -- Error handling: ~15 lines -- **Total**: ~100 lines - -**User Cognitive Load:** -- Learning preset concept: High -- Learning management commands: High -- Remembering preset names: Medium -- Understanding composition: Medium - -**Overall Leverage**: Medium-Low - -### Shell Aliases Approach Leverage Breakdown - -**Utility Provided:** -- Saves typing: High value (same as presets) -- Reduces errors: High value (same as presets) -- Enables sharing: High value (shell configs are shareable) -- Reduces learning: High value (leverages existing shell knowledge) - -**Code Complexity:** -- Default pattern logic: ~5 lines -- **Total**: ~5 lines - -**User Cognitive Load:** -- Learning shell aliases: Low (standard Unix knowledge) -- Creating custom aliases: Low (one-time setup) -- No rstring-specific concepts: Zero - -**Overall Leverage**: Very High - -## The "Do Users Want Docs/Config Files?" Question - -### Analysis of User Intent - -When users say "get my Python code," they might mean: -1. **Source only**: Just `.py` files in `src/`, exclude tests -2. **All Python**: Every `.py` file including tests and scripts -3. **Development context**: Python + docs + config files -4. **Everything relevant**: All files not in gitignore - -### The Assumption Problem - -Any built-in pattern makes assumptions about user intent: -- `--include=*.py` assumes they want all Python files -- `--include=src/` assumes specific directory structure -- Excluding tests assumes they don't want test context - -### The Conservative Solution - -**Default to maximum inclusion** (`--include=*/` + gitignore filtering): -- ✅ Makes zero assumptions about user preferences -- ✅ Respects project's own relevance decisions (gitignore) -- ✅ Easy to refine with additional flags -- ✅ Educational (shows what's in the project) - -## The Final Recommendation: Lean Core + Shell Aliases - -### Core Implementation - -```python -def get_default_patterns(): - """Conservative default: include everything, let gitignore filter.""" - return ['--include=*/'] - -# Usage in main(): -if not user_provided_patterns: - rsync_args = get_default_patterns() -``` - -### User Guidance - -**In README.md and --help:** -```markdown -## Creating Custom Shortcuts - -For frequently used complex patterns, create shell aliases: - -```bash -# In your .bashrc or .zshrc -alias rstring-py="rstring --include='*/' --include='*.py' --exclude='test*'" -alias rstring-web="rstring --include='*/' --include='*.js' --include='*.css' --include='*.html'" - -# Usage -rstring-py -rstring-web -C /path/to/project -``` - -### Why This Maximizes Leverage - -1. **Minimal Code**: Removes ~100 lines of preset management -2. **Zero Assumptions**: Conservative default works for everyone -3. **Maximum Power**: Full rsync capabilities always available -4. **User Control**: Shell aliases provide perfect customization -5. **Standard Practice**: Leverages existing Unix conventions -6. **Zero Learning Curve**: Works immediately, aliases are optional - -## Implementation Strategy - -### Phase 1: Remove Preset System -- Delete preset-related code from `cli.py` and `utils.py` -- Remove YAML dependency -- Simplify argument parsing -- Update help text - -### Phase 2: Enhance Documentation -- Add shell alias examples to README -- Include alias suggestions in `--help` output -- Create "Common Patterns" section with copy-pasteable aliases - -### Phase 3: Monitor Usage -- Observe if users request built-in patterns -- Consider adding minimal built-in profiles only if clear demand emerges - -## Risk Analysis - -### Potential Downsides - -1. **Increased Typing**: Users must type longer commands for specific patterns - - **Mitigation**: Shell aliases solve this for recurring needs - -2. **Discovery Problem**: Users might not know common patterns - - **Mitigation**: Documentation with examples - -3. **Team Coordination**: Harder to share patterns - - **Mitigation**: Teams can share shell config snippets - -### Success Metrics - -- **Code Reduction**: Remove ~100 lines of complexity -- **User Feedback**: Monitor for requests for built-in patterns -- **Adoption**: Track usage of documented alias patterns - -## Conclusion - -The preset system represents a classic engineering trade-off between convenience and complexity. While it solves real user problems, it does so at a high leverage cost. The **lean core + shell aliases** approach provides equivalent utility with dramatically lower complexity by leveraging existing Unix conventions. - -### Key Insights - -1. **Leverage is King**: 20x code reduction (100 lines → 5 lines) for equivalent utility -2. **Unix Philosophy**: Leverage existing tools (shell) rather than reinventing -3. **Conservative Defaults**: Avoid assumptions, let users specify intent -4. **Gitignore Integration**: Maximum leverage comes from using existing project standards - -### Final Recommendation - -**Remove the preset system entirely** and replace with: -- Conservative default behavior (`--include=*/` + gitignore) -- Comprehensive documentation of shell alias patterns -- Full preservation of rsync power and customizability - -This approach maximizes leverage while maintaining all the power and flexibility that makes rstring valuable. It trusts users to manage their own workflows using standard Unix tools rather than building a custom configuration system into rstring itself. \ No newline at end of file diff --git a/UX_ANALYSIS.md b/UX_ANALYSIS.md deleted file mode 100644 index 6b7320d..0000000 --- a/UX_ANALYSIS.md +++ /dev/null @@ -1,376 +0,0 @@ -# Rstring UX Analysis: Preset System and User Experience - -## Executive Summary - -The preset system is **architecturally sound but UX-problematic**. While it solves real problems and follows good engineering principles, it suffers from **discovery issues** and **cognitive overhead** that limit adoption. A serious engineer would build a preset system, but would implement it differently to address the UX pain points. - -## The Preset System: Problem Analysis - -### What Problem Does It Solve? - -1. **Rsync Pattern Complexity**: Rsync filter syntax is powerful but cryptic - ```bash - # This is intimidating for most users: - rstring --include=*/ --include=*.py --include=*.js --exclude=node_modules/ --exclude=__pycache__/ --exclude=*.pyc --exclude=.git/ --exclude=* - ``` - -2. **Repetitive Command Construction**: Users need the same patterns repeatedly -3. **Knowledge Sharing**: Teams need consistent file selection patterns -4. **Cognitive Load Reduction**: Abstract complex patterns into memorable names - -### Real-World Pain Points - -**Before presets (hypothetical):** -```bash -# User has to remember/reconstruct this every time: -rstring --include=*/ --include=*.py --include=*.js --include=*.ts --include=*.jsx --include=*.tsx --include=*.css --include=*.html --include=*.md --exclude=node_modules/ --exclude=dist/ --exclude=build/ --exclude=.git/ --exclude=__pycache__/ --exclude=*.pyc --exclude=* -``` - -**With presets:** -```bash -rstring --preset webdev -``` - -**Problem solved**: Reduces 200+ character command to 20 characters. - -## UX Evaluation: The Good - -### 1. **Conceptual Clarity** -The preset concept is immediately understandable: -- "Save this complex command as 'python'" -- "Use the saved 'python' command" -- Mental model aligns with user expectations - -### 2. **Sensible Defaults** -```yaml -common: - is_default: true - args: [extensive exclusion list] -``` -- Works out of the box for most projects -- Reduces initial friction -- Follows "convention over configuration" - -### 3. **Composability** -```bash -rstring --preset python --include=*.md # Extend preset with additional patterns -``` -- Presets don't lock users into rigid behavior -- Can be combined with ad-hoc patterns - -### 4. **Team Sharing** -```bash -rstring --save-preset team-python --include=src/ --include=*.py --exclude=test* -# Share ~/.rstring.yaml with team -``` -- Enables consistent patterns across teams -- Version-controllable configuration - -## UX Evaluation: The Painful - -### 1. **Discovery Problem** (Critical) - -**How does a new user discover what presets exist?** -```bash -$ rstring --help -# No mention of available presets in help output - -$ rstring --list-presets -Saved presets: - * common: --exclude=.git --exclude=__pycache__/* [... 50 more args] - everything: (no args) - pythonx: --include=*/ --include=*.py --exclude=* -``` - -**Problems:** -- Help doesn't show preset examples -- Preset list is overwhelming (50+ args for 'common') -- No description of what each preset is for -- No examples of usage - -### 2. **Cognitive Overhead** (Major) - -**Users must learn a meta-system before using the tool:** -1. Understand that presets exist -2. Learn preset commands (`--save-preset`, `--list-presets`, etc.) -3. Understand preset composition with other args -4. Remember preset names - -**This violates the "immediate utility" principle.** - -### 3. **Naming Confusion** (Minor but Real) - -```bash -$ rstring --list-presets - * common: [50 args] - everything: (no args) - pythonx: [3 args] -``` - -**Questions users have:** -- Why is "everything" empty but "common" has 50 exclusions? -- What's the difference between "python" and "pythonx"? -- What does "common" actually include/exclude? - -### 4. **Preset Management Complexity** (Minor) - -```bash -rstring --save-preset mypreset --include=*.py -rstring --delete-preset mypreset -rstring --set-default-preset mypreset -``` - -**Three different commands for preset management adds cognitive load.** - -## Alternative UX Approaches Analysis - -### Approach 1: No Presets (Simplest) -```bash -rstring --include=*.py --exclude=test* -``` - -**Pros:** -- Zero learning curve -- Immediate utility -- No hidden complexity - -**Cons:** -- Repetitive for complex patterns -- No knowledge sharing -- High cognitive load for complex filters - -**Verdict:** Too simplistic for real-world use - -### Approach 2: Smart Defaults Only -```bash -rstring # Uses intelligent defaults based on project detection -rstring --include=*.py # Override defaults -``` - -**Pros:** -- Zero configuration -- Immediate utility -- No preset management - -**Cons:** -- Magic behavior (hard to predict) -- Limited customization -- No team sharing - -**Verdict:** Good for 80% of cases, insufficient for power users - -### Approach 3: File-Type Shortcuts -```bash -rstring --python # Equivalent to --include=*/ --include=*.py --exclude=* -rstring --web # Equivalent to web development patterns -rstring --docs # Equivalent to documentation patterns -``` - -**Pros:** -- Self-documenting -- No preset management -- Immediate discovery - -**Cons:** -- Limited flexibility -- Hard-coded assumptions -- No customization - -**Verdict:** Better UX but less powerful - -### Approach 4: Improved Preset System (Recommended) - -**Enhanced preset discovery:** -```bash -$ rstring --help -Common presets: - --preset python # Python projects (.py files, exclude tests/cache) - --preset web # Web projects (.js/.css/.html, exclude node_modules) - --preset docs # Documentation (.md/.rst/.txt files) - -$ rstring --presets # Show detailed preset descriptions -``` - -**Enhanced preset creation:** -```bash -rstring --save-preset python "Python development files" --include=*.py --exclude=test* -``` - -**Enhanced preset listing:** -```bash -$ rstring --list-presets -Available presets: - * common - General purpose (excludes build/cache dirs) - python - Python development files - web - Web development files - docs - Documentation files -``` - -## Serious Engineer Assessment - -### Would a Serious Engineer Build a Preset System? - -**Yes, but differently.** The core concept is sound: - -1. **Real Problem**: Rsync patterns are complex and repetitive -2. **Good Abstraction**: Named patterns reduce cognitive load -3. **Composable**: Presets + ad-hoc patterns work well together -4. **Shareable**: Teams need consistent patterns - -### What Would They Do Differently? - -#### 1. **Discovery-First Design** -```bash -$ rstring --help -# Show preset examples prominently in help - -$ rstring --preset -# Interactive preset selection with descriptions -``` - -#### 2. **Self-Documenting Presets** -```yaml -presets: - python: - description: "Python development files (.py, exclude tests/cache)" - args: [--include=*.py, --exclude=test*] - examples: - - "rstring --preset python" - - "rstring --preset python --include=*.md" -``` - -#### 3. **Simplified Management** -```bash -# Instead of --save-preset, --delete-preset, --set-default-preset -rstring preset save python --include=*.py -rstring preset delete python -rstring preset default python -rstring preset list -``` - -#### 4. **Better Defaults** -```bash -# Auto-detect project type and suggest presets -$ rstring -Detected Python project. Suggested preset: --preset python -Using default preset 'common'. Use --preset python for Python-specific filtering. -``` - -## Broader UX Analysis - -### What Works Well - -#### 1. **Immediate Utility** -```bash -rstring # Works immediately with sensible defaults -``` - -#### 2. **Progressive Disclosure** -- Basic usage is simple -- Advanced features are discoverable -- Power users can access full rsync capabilities - -#### 3. **Composability** -```bash -rstring --preset python --include=*.md --summary -``` -- Features combine predictably -- No feature conflicts - -#### 4. **Familiar Patterns** -- `--help` for help -- `--preset` follows CLI conventions -- Rsync patterns for power users - -### What's Problematic - -#### 1. **Steep Learning Curve for Power** -To use rstring effectively, users must learn: -- Rsync include/exclude syntax -- Preset system -- Interactive mode -- Various output options - -#### 2. **Error Messages** -```bash -$ rstring --include=*.py /nonexistent -Error: Directory '/nonexistent' does not exist. -``` -**Good error, but could be more helpful:** -```bash -Error: Directory '/nonexistent' does not exist. -Tip: Use 'rstring -C /path/to/project' to specify a different directory. -``` - -#### 3. **Discoverability** -- No built-in examples -- No guided tour for new users -- Advanced features are hidden - -## Recommendations for UX Improvement - -### High-Impact, Low-Effort - -1. **Improve help output:** - ```bash - $ rstring --help - # Add preset examples and common patterns - ``` - -2. **Better preset descriptions:** - ```bash - $ rstring --list-presets - # Show what each preset is for, not just the args - ``` - -3. **Suggest presets:** - ```bash - $ rstring --include=*.py - # Tip: Use '--preset python' for Python-specific patterns - ``` - -### Medium-Impact, Medium-Effort - -1. **Interactive preset creation:** - ```bash - $ rstring --create-preset - # Guided preset creation wizard - ``` - -2. **Project-type detection:** - ```bash - $ rstring - # Auto-suggest appropriate presets based on project files - ``` - -3. **Better error messages with suggestions** - -### High-Impact, High-Effort - -1. **Complete preset system redesign** with discovery-first approach -2. **Interactive mode improvements** with better UX -3. **Web-based preset sharing** community - -## Conclusion - -The preset system **solves real problems** and follows **good engineering principles**, but suffers from **UX execution issues**. The core concept is sound—a serious engineer would build a preset system—but would prioritize **discovery and usability** over **feature completeness**. - -### Key Insights - -1. **The problem is real**: Rsync patterns are too complex for casual use -2. **The solution is sound**: Named presets reduce cognitive load -3. **The execution is flawed**: Discovery and usability issues limit adoption -4. **The fix is achievable**: Better help, descriptions, and guidance - -The preset system represents **good engineering with poor UX design**. It's architecturally correct but user-hostile. A serious engineer would recognize this and prioritize the UX improvements that make the powerful system actually usable. - -### UX Scorecard -- **Conceptual clarity**: ✅ Good -- **Immediate utility**: ✅ Good (with defaults) -- **Progressive disclosure**: ⚠️ Needs work -- **Discoverability**: ❌ Poor -- **Error handling**: ⚠️ Adequate -- **Learning curve**: ❌ Too steep -- **Power user satisfaction**: ✅ Good - -**Overall**: Good foundation, needs UX polish to reach its potential. \ No newline at end of file diff --git a/defunct/stringify-old.py b/defunct/stringify-old.py deleted file mode 100755 index 810af4e..0000000 --- a/defunct/stringify-old.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python - -import argparse -import fnmatch -import os - -def main(): - parser = argparse.ArgumentParser(description='Gather code from a directory into a single string with file paths labeled.') - parser.add_argument('directory', type=str, help='Root directory to gather code from') - parser.add_argument('-o', '--output', type=str, help='Output file (default is stdout)', default=None) - parser.add_argument('-i', '--include', action='append', help='Include patterns (glob format, repeatable)', default=["*"]) - parser.add_argument('-e', '--exclude', action='append', help='Exclude patterns (glob format, repeatable)', default=[]) - - args = parser.parse_args() - - gather_code(args.directory, args.output, args.include, args.exclude) - -def gather_code(directory, output_file, includes, excludes): - result = "" - for root, dirs, files in os.walk(directory, topdown=True): - # Exclude specified directories - dirs[:] = [d for d in dirs if not any(fnmatch.fnmatch(os.path.join(root, d), pat) for pat in excludes)] - - # Apply include patterns - if includes: - files = [f for f in files if any(fnmatch.fnmatch(os.path.join(root, f), pat) for pat in includes)] - - # Exclude specified files - relative_root = os.path.relpath(root, start=directory) - files = [f for f in files if not any(fnmatch.fnmatch(os.path.join(relative_root, f), pat) for pat in excludes)] - - for file in files: - file_path = os.path.join(root, file) - try: - with open(file_path, 'r', encoding='utf-8', errors='ignore') as file_content: - file_data = file_content.read() - result += f'--- {os.path.relpath(file_path, start=directory)} ---\n{file_data}\n' - except Exception as e: - print(f"Error reading {file_path}: {e}") - - if output_file: - with open(output_file, 'w', encoding='utf-8') as f: - f.write(result) - else: - print(result) - -if __name__ == '__main__': - main() - diff --git a/defunct/stringify.py.before b/defunct/stringify.py.before deleted file mode 100755 index 4794cd1..0000000 --- a/defunct/stringify.py.before +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python -import argparse -import os -import fnmatch -import re - -def parse_arguments(): - parser = argparse.ArgumentParser(description='Gather code from a directory into a single string with file paths labeled.') - parser.add_argument('directory', type=str, help='Root directory to gather code from') - parser.add_argument('-o', '--output', type=str, help='Output file (default is stdout)', default=None) - parser.add_argument('patterns', nargs='*', help='Include/exclude patterns (prefix with - to exclude)') - return parser.parse_args() - -def is_binary(file_path): - """Check if file is binary""" - with open(file_path, 'rb') as file: - return b'\0' in file.read(1024) - -def match_path(path, pattern): - """Match a path against a pattern, supporting rsync-like wildcards""" - if pattern.startswith('/'): - pattern = pattern[1:] # Remove leading '/' for anchored patterns - else: - pattern = f'**/{pattern}' # Unanchored patterns can match anywhere in the path - - # Convert rsync-like pattern to regex - regex = fnmatch.translate(pattern) - regex = regex.replace(r'\Z(?ms)', '') # Remove end of string match - regex = regex.replace('**/', '(.*\/)*') # Support '**' wildcard - - return re.match(regex, path) is not None - -def should_include(path, patterns): - """Determine if a path should be included based on the patterns""" - include = True # Default to include - for pattern in patterns: - if pattern.startswith('-'): # Exclude pattern - if match_path(path, pattern[1:]): - include = False - elif match_path(path, pattern): # Include pattern - include = True - return include - -def gather_code(directory, patterns): - result = "" - for root, dirs, files in os.walk(directory, topdown=True): - rel_root = os.path.relpath(root, directory) - print(f"Processing directory: {rel_root}") # Debug output - - # Filter directories - dirs[:] = [d for d in dirs if should_include(os.path.join(rel_root, d + '/'), patterns)] - print(f"Dirs after filtering: {dirs}") # Debug output - - # Process files - for file in files: - file_path = os.path.join(rel_root, file) - print(f"Checking file: {file_path}") # Debug output - if should_include(file_path, patterns): - print(f"Including file: {file_path}") # Debug output - full_path = os.path.join(root, file) - try: - if is_binary(full_path): - result += f'--- {file_path} ---\n[Binary file]\n' - else: - with open(full_path, 'r', encoding='utf-8', errors='ignore') as file_content: - file_data = file_content.read() - result += f'--- {file_path} ---\n{file_data}\n' - except Exception as e: - print(f"Error reading {file_path}: {e}") - else: - print(f"Excluding file: {file_path}") # Debug output - - return result - -def main(): - args = parse_arguments() - - # Handle trailing slash in directory - if args.directory.endswith('/'): - base_dir = os.path.dirname(args.directory.rstrip('/')) - if not base_dir: - base_dir = '..' - patterns = ['*/'] + args.patterns - else: - base_dir = os.path.dirname(args.directory) - if not base_dir: - base_dir = '..' - patterns = [os.path.basename(args.directory)] + args.patterns - - print(f"Base directory: {base_dir}") # Debug output - print(f"Patterns: {patterns}") # Debug output - - result = gather_code(base_dir, patterns) - - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - f.write(result) - else: - print(result) - -if __name__ == '__main__': - main() diff --git a/defunct/stringify.py.lastbeforersync b/defunct/stringify.py.lastbeforersync deleted file mode 100755 index afdaea4..0000000 --- a/defunct/stringify.py.lastbeforersync +++ /dev/null @@ -1,128 +0,0 @@ -# #!/usr/bin/env python -# import os -# import fnmatch -# import sys -# import logging -# -# logging.basicConfig(level=logging.DEBUG) -# logger = logging.getLogger(__name__) -# -# -# def parse_arguments(): -# if len(sys.argv) < 2: -# print("Usage: python stringify.py [patterns...]") -# sys.exit(1) -# -# directory = sys.argv[1] -# patterns = sys.argv[2:] -# return directory, patterns -# -# -# def is_binary(file_path): -# try: -# with open(file_path, 'rb') as file: -# return b'\0' in file.read(1024) -# except IOError: -# return False -# -# -# def match_pattern(path, pattern): -# logger.debug(f"Matching path: {path} against pattern: {pattern}") -# logger.debug(f"Path components: {path.split(os.sep)}") -# logger.debug(f"Pattern components: {pattern.split(os.sep)}") -# if pattern.startswith('/'): -# result = fnmatch.fnmatch('/' + path, pattern) -# elif '**' in pattern: -# parts = pattern.split('**') -# result = path.startswith(parts[0]) and path.endswith(parts[-1]) and all(part in path for part in parts[1:-1]) -# else: -# result = fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(os.path.basename(path), pattern) -# logger.debug(f"Match result: {result}") -# return result -# -# -# def build_file_list(directory, patterns): -# logger.debug(f"Building file list for directory: {directory}") -# logger.debug(f"Patterns: {patterns}") -# -# all_files = set() -# for root, dirs, files in os.walk(directory): -# for file in files: -# all_files.add(os.path.relpath(os.path.join(root, file), directory)) -# -# logger.debug(f"All files: {all_files}") -# -# include_only_mode = any(not p.startswith('-') for p in patterns) -# logger.debug(f"Include-only mode: {include_only_mode}") -# -# result = set() -# excluded = set() -# -# logger.debug(f"Patterns is {patterns}") -# for pattern in patterns: -# logger.debug(f"Processing pattern: {pattern}") -# is_exclude = pattern.startswith('-') -# logger.debug(f"Is exclude: {is_exclude}") -# pattern = pattern[1:] if is_exclude else pattern -# logger.debug(f"Pattern after stripping '-' if present: {pattern}") -# -# matched = set(f for f in all_files if match_pattern(f, pattern)) -# logger.debug(f"Matched files for pattern {pattern}: {matched}") -# -# if is_exclude: -# excluded.update(matched) -# # Exclude contents of matched directories -# for path in matched: -# if os.path.isdir(os.path.join(directory, path)): -# excluded.update(f for f in all_files if f.startswith(path + os.sep)) -# else: -# result.update(matched) -# for path in all_files: -# if match_pattern(path, pattern): -# if is_exclude: -# excluded.add(path) -# logger.debug(f"Excluded based on pattern {pattern}: {path}") -# else: -# result.add(path) -# logger.debug(f"Included based on pattern {pattern}: {path}") -# -# logger.debug(f"Files after pattern matching - Included: {result}, Excluded: {excluded}") -# -# if include_only_mode: -# final_result = result - excluded -# else: -# final_result = all_files - excluded -# final_result.update(result) -# -# logger.debug(f"Final result after mode application: {final_result}") -# return sorted(final_result) -# -# -# def gather_code(directory, file_list): -# result = "" -# for file_path in file_list: -# full_path = os.path.join(directory, file_path) -# if os.path.isfile(full_path): -# try: -# if is_binary(full_path): -# result += f'--- {file_path} ---\n[Binary file]\n\n' -# else: -# with open(full_path, 'r', encoding='utf-8', errors='ignore') as file_content: -# file_data = file_content.read() -# result += f'--- {file_path} ---\n{file_data}\n\n' -# except Exception as e: -# logger.error(f"Error reading {file_path}: {e}") -# return result -# -# -# def main(): -# directory, patterns = parse_arguments() -# directory = directory.rstrip('/') -# -# file_list = build_file_list(directory, patterns) -# result = gather_code(directory, file_list) -# print(result) -# -# -# if __name__ == '__main__': -# main() diff --git a/defunct/stringify.py.singlefile b/defunct/stringify.py.singlefile deleted file mode 100755 index 316e199..0000000 --- a/defunct/stringify.py.singlefile +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env python - -import argparse -import binascii -import json -import logging -import os -import platform -import shlex -import subprocess - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - -PRESETS_FILE = os.path.expanduser("~/.stringify.yaml") - - -def load_presets(): - if os.path.exists(PRESETS_FILE): - try: - with open(PRESETS_FILE, 'r') as f: - return json.load(f) - except json.JSONDecodeError: - logger.error(f"Invalid JSON in {PRESETS_FILE}. Using empty presets.") - print(f"Warning: Invalid JSON in {PRESETS_FILE}. Using empty presets.") - except Exception as e: - logger.error(f"Error reading {PRESETS_FILE}: {e}") - print(f"Warning: Error reading {PRESETS_FILE}. Using empty presets.") - return {} - - -def save_presets(presets): - with open(PRESETS_FILE, 'w') as f: - json.dump(presets, f, indent=2) - - -def check_rsync(): - try: - subprocess.run(["rsync", "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return True - except (subprocess.CalledProcessError, FileNotFoundError): - return False - - -def run_rsync(args): - cmd = ["rsync", "-ain", "--list-only"] + args - logger.debug(f"Rsync command: {' '.join(cmd)}") - - try: - result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - logger.debug(f"Rsync stdout: {result.stdout}") - logger.debug(f"Rsync stderr: {result.stderr}") - return parse_rsync_output(result.stdout) - except subprocess.CalledProcessError as e: - logger.error(f"Rsync command failed: {e}") - logger.error(f"Stdout: {e.stdout}") - logger.error(f"Stderr: {e.stderr}") - raise - - -def validate_rsync_args(args): - try: - run_rsync(args) - return True - except subprocess.CalledProcessError: - return False - - -def parse_rsync_output(output): - file_list = [] - for line in output.splitlines(): - parts = line.split() - if len(parts) >= 5 and not line.endswith('/'): - file_path = ' '.join(parts[4:]) - if file_path != '.': # Exclude the root directory - file_list.append(file_path) - return file_list - - -def is_binary(file_path): - try: - with open(file_path, 'rb') as file: - return b'\0' in file.read(1024) - except IOError: - return False - - -def gather_code(file_list, preview_length=None): - result = "" - for file_path in file_list: - full_path = file_path - if os.path.isfile(full_path): - try: - with open(full_path, 'rb') as file_content: - if is_binary(full_path): - if preview_length is None or preview_length > 0: - file_data = f"[Binary file, first 32 bytes: {binascii.hexlify(file_content.read(32)).decode()}]" - else: - file_data = file_content.read().decode('utf-8', errors='ignore') - file_data = '\n'.join(file_data.splitlines()[:preview_length]) - if preview_length is None or preview_length > 0: - result += f"--- {file_path} ---\n{file_data}\n\n" - else: - result += f"--- {file_path} ---\n\n" - except Exception as e: - logger.error(f"Error reading {file_path}: {e}") - else: - result += f"--- {file_path} ---\n[Directory]\n\n" - return result - - -def interactive_mode(initial_args): - args = initial_args.copy() - while True: - print(args) - if not validate_rsync_args(args): - print("Error: Invalid rsync arguments. Please try again.") - continue - - file_list = run_rsync(args) - print("\nCurrent file list:") - print_tree(file_list) - # for file in file_list: - # if os.path.isfile(file): - # print(file) - print(f"\nCurrent rsync arguments: {' '.join(args)}") - - action = input("\nEnter an action (a)dd/(r)emove/(e)dit/(d)one: ").lower() - if action in ['done', 'd']: - break - elif action in ['add', 'a']: - pattern = input("Enter a pattern: ") - args.extend(['--include', pattern]) - elif action in ['remove', 'r']: - pattern = input("Enter a pattern: ") - args.extend(['--exclude', pattern]) - elif action in ['edit', 'e']: - args_str = input("Enter the new rsync arguments: ") - new_args = shlex.split(args_str) - if not any(arg for arg in new_args if not arg.startswith('--')): - new_args.append('.') - if validate_rsync_args(new_args): - args = new_args - else: - print("Error: Invalid rsync arguments. Please try again.") - else: - print("Invalid action. Please enter 'a', 'r', 'e', or 'd'.") - - return args - - -def print_tree(file_list): - tree = {} - for file_path in file_list: - parts = file_path.split(os.sep) - current = tree - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - current[parts[-1]] = {} - - def print_tree_recursive(node, prefix=""): - items = list(node.items()) - for i, (name, subtree) in enumerate(items): - if i == len(items) - 1: - print(f"{prefix}└── {name}") - new_prefix = prefix + " " - else: - print(f"{prefix}├── {name}") - new_prefix = prefix + "│ " - if subtree: - print_tree_recursive(subtree, new_prefix) - - print_tree_recursive(tree) - - -def copy_to_clipboard(text, file_list): - 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) - print(f"Copied {len(text)} chars from {len(text.splitlines())} lines from {len(file_list)} files to clipboard.") - except Exception as e: - print(f"Failed to copy to clipboard: {e}") - - -def main(): - if not check_rsync(): - print("Error: rsync is not installed on this system. Please install rsync and try again.") - return - - parser = argparse.ArgumentParser(description="Stringify code with rsync and manage presets.") - parser.add_argument("-p", "--preset", help="Use a saved preset") - parser.add_argument("-sp", "--save-preset", nargs=2, metavar=("NAME", "ARGS"), help="Save a new preset") - parser.add_argument("-sap", "--save-as-preset", metavar="NAME", help="Save the current 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("-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", help="Show only the first N lines of each file") - parser.add_argument("-s", "--summary", action="store_true", help="Print a summary including a tree of files") - # parser.add_argument('rsync_args', nargs=argparse.REMAINDER, help="Additional rsync arguments") - - args, unknown_args = parser.parse_known_args() - - presets = load_presets() - - if args.list_presets: - print("Saved presets:") - for name, preset_args in presets.items(): - print(f" {name}: {' '.join(preset_args)}") - return - - if args.save_preset: - name, preset_args = args.save_preset - presets[name] = shlex.split(preset_args) - save_presets(presets) - print(f"Preset '{name}' saved.") - 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.") - else: - print(f"Preset '{args.delete_preset}' not found.") - return - - rsync_args = presets.get(args.preset, []) if args.preset else [] - # rsync_args.extend(args.rsync_args) - rsync_args.extend(unknown_args) - - # Add default directory (.) if no source directory is provided - 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 - - if args.interactive: - rsync_args = interactive_mode(rsync_args) - - file_list = run_rsync(rsync_args) - result = gather_code(file_list, args.preview_length) - - if args.no_clipboard: - print(result) - else: - copy_to_clipboard(result, file_list) - - if args.summary: - if args.preset: - if rsync_args != presets[args.preset]: - print(f"Using preset '{args.preset}' with modified rsync options: {' '.join(rsync_args)}") - else: - print(f"Using preset '{args.preset}'") - else: - print(f"Using rsync options: {' '.join(rsync_args)}") - print("\nFile tree:") - print_tree(file_list) - - if args.save_as_preset: - presets[args.save_as_preset] = rsync_args - save_presets(presets) - print(f"Preset '{args.save_as_preset}' saved.") - - -if __name__ == '__main__': - main() diff --git a/defunct/test_stringify.py.before b/defunct/test_stringify.py.before deleted file mode 100755 index 000bea5..0000000 --- a/defunct/test_stringify.py.before +++ /dev/null @@ -1,121 +0,0 @@ -import os -import subprocess -import tempfile -import shutil -import unittest -import signal - -class TimeoutException(Exception): - pass - -def timeout_handler(signum, frame): - raise TimeoutException("Test timed out") - - -class TestStringify(unittest.TestCase): - @classmethod - def setUpClass(cls): - # Create a temporary directory structure for testing - cls.test_dir = tempfile.mkdtemp() - cls.create_test_file_structure(cls.test_dir) - - @classmethod - def tearDownClass(cls): - # Clean up the temporary directory - shutil.rmtree(cls.test_dir) - - @classmethod - def create_test_file_structure(cls, root): - # Create a complex directory structure for testing - os.makedirs(os.path.join(root, "src", "lib")) - os.makedirs(os.path.join(root, "docs")) - os.makedirs(os.path.join(root, "tests")) - os.makedirs(os.path.join(root, "node_modules", "package")) - os.makedirs(os.path.join(root, "logs", "old")) - - # Create some test files - open(os.path.join(root, "file1.txt"), "w").close() - open(os.path.join(root, "file2.txt"), "w").close() - open(os.path.join(root, "src", "main.py"), "w").close() - open(os.path.join(root, "src", "lib", "util.py"), "w").close() - open(os.path.join(root, "docs", "readme.md"), "w").close() - open(os.path.join(root, "tests", "test_main.py"), "w").close() - open(os.path.join(root, "node_modules", "package", "index.js"), "w").close() - open(os.path.join(root, "logs", "app.log"), "w").close() - open(os.path.join(root, "logs", "old", "app_2023.log"), "w").close() - - def run_stringify(self, *args, timeout=5): - cmd = ["python", "stringify.py", self.test_dir] + list(args) - - signal.signal(signal.SIGALRM, timeout_handler) - signal.alarm(timeout) - - try: - result = subprocess.run(cmd, capture_output=True, text=True) - signal.alarm(0) # Cancel the alarm - return result.stdout - except TimeoutException: - self.fail(f"Test timed out after {timeout} seconds") - - def test_default_behavior(self): - output = self.run_stringify() - self.assertIn("file1.txt", output) - self.assertIn("src/main.py", output) - self.assertIn("node_modules/package/index.js", output) - - def test_single_include(self): - output = self.run_stringify("-i", "*.txt") - self.assertIn("file1.txt", output) - self.assertIn("file2.txt", output) - self.assertNotIn("src/main.py", output) - - def test_single_exclude(self): - output = self.run_stringify("-e", "*.log") - self.assertIn("file1.txt", output) - self.assertNotIn("logs/app.log", output) - self.assertNotIn("logs/old/app_2023.log", output) - - def test_include_then_exclude(self): - output = self.run_stringify("-i", "*.py", "-e", "test_*.py") - self.assertIn("src/main.py", output) - self.assertNotIn("tests/test_main.py", output) - - def test_exclude_then_include(self): - output = self.run_stringify("-e", "*.py", "-i", "src/*.py") - self.assertIn("src/main.py", output) - self.assertNotIn("tests/test_main.py", output) - - def test_double_asterisk(self): - output = self.run_stringify("-i", "**/*.py") - self.assertIn("src/main.py", output) - self.assertIn("src/lib/util.py", output) - self.assertIn("tests/test_main.py", output) - - def test_exclude_directory(self): - output = self.run_stringify("-e", "node_modules/") - self.assertIn("file1.txt", output) - self.assertNotIn("node_modules/package/index.js", output) - - def test_include_file_in_excluded_directory(self): - output = self.run_stringify("-e", "node_modules/", "-i", "node_modules/package/index.js") - self.assertIn("node_modules/package/index.js", output) - self.assertNotIn("node_modules/some_other_file.js", output) - - def test_anchored_path(self): - output = self.run_stringify("-e", "/logs/old/*.log") - self.assertIn("logs/app.log", output) - self.assertNotIn("logs/old/app_2023.log", output) - - def test_unanchored_path(self): - output = self.run_stringify("-e", "*/old/*") - self.assertIn("logs/app.log", output) - self.assertNotIn("logs/old/app_2023.log", output) - - def test_complex_scenario(self): - output = self.run_stringify("-i", "*.py", "-e", "test_*.py", "-i", "test_critical*.py", "-e", "**/deprecated/**") - self.assertIn("src/main.py", output) - self.assertNotIn("tests/test_main.py", output) - # We would need to add a test_critical.py file to fully test this scenario - -if __name__ == '__main__': - unittest.main() diff --git a/defunct/test_stringify.py.disabled b/defunct/test_stringify.py.disabled deleted file mode 100644 index f87e430..0000000 --- a/defunct/test_stringify.py.disabled +++ /dev/null @@ -1,154 +0,0 @@ -import logging -import os -import shutil -import subprocess -import unittest - -logging.basicConfig(level=logging.DEBUG) -logger = logging.getLogger(__name__) - - -class TestStringify(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.test_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'test_data')) - cls.create_test_file_structure(cls.test_dir) - - @classmethod - def tearDownClass(cls): - shutil.rmtree(cls.test_dir) - - @classmethod - def create_test_file_structure(cls, root): - os.makedirs(os.path.join(root, "src", "lib")) - os.makedirs(os.path.join(root, "docs")) - os.makedirs(os.path.join(root, "tests")) - os.makedirs(os.path.join(root, "node_modules", "package")) - os.makedirs(os.path.join(root, "logs", "old")) - - open(os.path.join(root, "file1.txt"), "w").close() - open(os.path.join(root, "file2.txt"), "w").close() - open(os.path.join(root, "src", "main.py"), "w").close() - open(os.path.join(root, "src", "lib", "util.py"), "w").close() - open(os.path.join(root, "docs", "readme.md"), "w").close() - open(os.path.join(root, "tests", "test_main.py"), "w").close() - open(os.path.join(root, "node_modules", "package", "index.js"), "w").close() - open(os.path.join(root, "logs", "app.log"), "w").close() - open(os.path.join(root, "logs", "old", "app_2023.log"), "w").close() - - def run_stringify(self, *args): - cmd = ["python", "stringify.py", self.test_dir] + list(args) - result = subprocess.run(cmd, capture_output=True, text=True) - return result.stdout, result.stderr, result.returncode - - def assertInWithContext(self, member, container, msg=None): - try: - self.assertIn(member, container[0], msg) - except AssertionError: - logger.debug( - f"\nFailed test command: python stringify.py {self.test_dir} {' '.join(self._testMethodName.split('_')[1:])}") - logger.debug(f"STDOUT:\n{container[0]}") - logger.debug(f"STDERR:\n{container[1]}") - raise - - def assertNotInWithContext(self, member, container, msg=None): - try: - self.assertNotIn(member, container[0], msg) - except AssertionError: - logger.debug( - f"\nFailed test command: python stringify.py {self.test_dir} {' '.join(self._testMethodName.split('_')[1:])}") - logger.debug(f"STDOUT:\n{container[0]}") - logger.debug(f"STDERR:\n{container[1]}") - raise - - # def test_default_behavior(self): - # output = self.run_stringify() - # self.assertInWithContext("file1.txt", output) - # self.assertInWithContext("src/main.py", output) - # self.assertInWithContext("node_modules/package/index.js", output) - # - # def test_single_include(self): - # output = self.run_stringify("*.txt") - # self.assertInWithContext("file1.txt", output) - # self.assertInWithContext("file2.txt", output) - # self.assertNotInWithContext("src/main.py", output) - # - # def test_single_exclude(self): - # output = self.run_stringify("-*.log") - # self.assertInWithContext("file1.txt", output) - # self.assertNotInWithContext("logs/app.log", output) - # self.assertNotInWithContext("logs/old/app_2023.log", output) - # - # def test_include_then_exclude(self): - # output = self.run_stringify("*.py", "-test_*.py") - # self.assertInWithContext("src/main.py", output) - # self.assertNotInWithContext("tests/test_main.py", output) - # - # def test_exclude_then_include(self): - # output = self.run_stringify("-*.py", "src/*.py") - # self.assertInWithContext("src/main.py", output) - # self.assertNotInWithContext("tests/test_main.py", output) - # - # def test_double_asterisk(self): - # output = self.run_stringify("**/*.py") - # self.assertInWithContext("src/main.py", output) - # self.assertInWithContext("src/lib/util.py", output) - # self.assertInWithContext("tests/test_main.py", output) - - def test_exclude_directory(self): - output = self.run_stringify("-node_modules") - self.assertInWithContext("file1.txt", output) - self.assertNotInWithContext("node_modules/package/index.js", output) - # - # def test_include_file_in_excluded_directory(self): - # output = self.run_stringify("-node_modules", "node_modules/package/index.js") - # self.assertInWithContext("node_modules/package/index.js", output) - # self.assertNotInWithContext("node_modules/some_other_file.js", output) - # - # def test_anchored_path(self): - # output = self.run_stringify("-/logs/old/*.log") - # self.assertInWithContext("logs/app.log", output) - # self.assertNotInWithContext("logs/old/app_2023.log", output) - # - # def test_unanchored_path(self): - # output = self.run_stringify("-*/old/*") - # self.assertInWithContext("logs/app.log", output) - # self.assertNotInWithContext("logs/old/app_2023.log", output) - # - # def test_complex_scenario(self): - # output = self.run_stringify("*.py", "src/**", "-test_*.py", "test_critical*.py", "-**/deprecated/**") - # self.assertInWithContext("src/main.py", output) - # self.assertNotInWithContext("tests/test_main.py", output) - # - # # New test cases - # def test_include_directory_contents(self): - # output = self.run_stringify("src/", "src/**") - # self.assertInWithContext("src/main.py", output) - # self.assertInWithContext("src/lib/util.py", output) - # self.assertNotInWithContext("file1.txt", output) - # - # def test_exclude_directory_include_subdirectory(self): - # output = self.run_stringify("-logs/", "logs/old/", "logs/old/**") - # self.assertNotInWithContext("logs/app.log", output) - # self.assertInWithContext("logs/old/app_2023.log", output) - # - # def test_include_only_mode(self): - # output = self.run_stringify("*.py") - # self.assertInWithContext("src/main.py", output) - # self.assertNotInWithContext("file1.txt", output) - # self.assertNotInWithContext("logs/app.log", output) - # - # def test_parent_directory_inclusion(self): - # output = self.run_stringify("src/lib/util.py") - # self.assertInWithContext("src/lib/util.py", output) - # self.assertNotInWithContext("src/main.py", output) - # - # def test_multiple_patterns(self): - # output = self.run_stringify("*.txt", "*.py", "-test_*.py") - # self.assertInWithContext("file1.txt", output) - # self.assertInWithContext("src/main.py", output) - # self.assertNotInWithContext("tests/test_main.py", output) - - -if __name__ == '__main__': - unittest.main(verbosity=2) diff --git a/defunct/test_stringify_rsync.py.disabled b/defunct/test_stringify_rsync.py.disabled deleted file mode 100644 index e16a1bb..0000000 --- a/defunct/test_stringify_rsync.py.disabled +++ /dev/null @@ -1,72 +0,0 @@ -import unittest -import os -import shutil -import subprocess -import tempfile -import json - -class TestStringifyRsync(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.test_dir = tempfile.mkdtemp() - cls.create_test_file_structure(cls.test_dir) - cls.script_path = os.path.abspath("stringify_rsync.py") - cls.test_preset_file = os.path.join(cls.test_dir, ".test_presets.json") - os.environ["STRINGIFY_PRESET_FILE"] = cls.test_preset_file - - @classmethod - def tearDownClass(cls): - shutil.rmtree(cls.test_dir) - if os.path.exists(cls.test_preset_file): - os.remove(cls.test_preset_file) - - @classmethod - def create_test_file_structure(cls, root): - os.makedirs(os.path.join(root, "src")) - os.makedirs(os.path.join(root, "tests")) - - files = [ - "file1.txt", "file2.py", - os.path.join("src", "main.py"), - os.path.join("../tests", "test_main.py") - ] - - for file in files: - with open(os.path.join(root, file), "w") as f: - f.write(f"Content of {file}") - - def run_stringify(self, *args): - cmd = ["python", self.script_path, self.test_dir] + list(args) - result = subprocess.run(cmd, capture_output=True, text=True) - return result.stdout, result.stderr, result.returncode - - def test_default_behavior(self): - output, _, _ = self.run_stringify() - self.assertIn("file1.txt", output) - self.assertIn("file2.py", output) - self.assertIn("src/main.py", output) - self.assertIn("tests/test_main.py", output) - - def test_save_and_use_preset(self): - _, _, rc = self.run_stringify("--save-preset", "py_only", "--include=*.py --exclude=*") - self.assertEqual(rc, 0) - - output, _, _ = self.run_stringify("--preset", "py_only") - self.assertNotIn("file1.txt", output) - self.assertIn("file2.py", output) - self.assertIn("src/main.py", output) - self.assertIn("tests/test_main.py", output) - - def test_list_presets(self): - self.run_stringify("--save-preset", "test_preset", "--include=*.txt") - output, _, _ = self.run_stringify("--list-presets") - self.assertIn("test_preset", output) - - def test_delete_preset(self): - self.run_stringify("--save-preset", "to_delete", "--include=*.txt") - self.run_stringify("--delete-preset", "to_delete") - output, _, _ = self.run_stringify("--list-presets") - self.assertNotIn("to_delete", output) - -if __name__ == '__main__': - unittest.main() \ No newline at end of file diff --git a/nothin/emptyfile.txt b/nothin/emptyfile.txt deleted file mode 100644 index e69de29..0000000 diff --git a/nothin/nonemptyfile.txt b/nothin/nonemptyfile.txt deleted file mode 100644 index 45b983b..0000000 --- a/nothin/nonemptyfile.txt +++ /dev/null @@ -1 +0,0 @@ -hi diff --git a/system_limits_audit.md b/system_limits_audit.md deleted file mode 100644 index fdc214c..0000000 --- a/system_limits_audit.md +++ /dev/null @@ -1,90 +0,0 @@ -# System Limits Audit Log - -## Session: 2024-12-30 - inotify Limits Optimization - -### System Specs -- **CPU**: AMD Ryzen 9 7900X 12-Core (24 threads) -- **RAM**: 124GB -- **Storage**: 1.4TB NVMe (71% used) -- **OS**: Kubuntu (Linux 6.14.0-15-generic) - -### Problem Identified -- `journalctl -f` failing with "Insufficient watch descriptors available" -- Webpack watch mode issues -- **Root cause**: `max_user_instances` limit (128) nearly exhausted (119/128 used) - -### Current Limits (Before Changes) -```bash -fs.inotify.max_user_instances = 128 -fs.inotify.max_user_watches = 1009491 -fs.inotify.max_queued_events = 16384 -ulimit -n = 16777216 (file descriptors - already optimal) -``` - -### Changes Applied - -#### 1. Created permanent inotify limits configuration ✅ -**File**: `/etc/sysctl.d/99-dev-limits.conf` -**Action**: Created new sysctl configuration file -**Timestamp**: 2024-12-30 - -```bash -# Development-optimized inotify limits for high-end workstation -# Applied: 2024-12-30 -# Reason: Fix journalctl -f failures and webpack watch issues - -# Increase inotify instances (was 128, now 2048) -fs.inotify.max_user_instances = 2048 - -# Increase queued events for rapid file changes (was 16384, now 65536) -fs.inotify.max_queued_events = 65536 - -# Keep existing watch limit (already adequate at ~1M) -# fs.inotify.max_user_watches = 1009491 -``` - -### Files Checked for Conflicts - -#### ✅ `/etc/sysctl.conf` -- **Status**: No inotify settings found - no conflicts - -#### ✅ `/etc/sysctl.d/*.conf` -- **Status**: No conflicting inotify settings in other files -- **Files present**: 10-bufferbloat.conf, 10-console-messages.conf, 10-ipv6-privacy.conf, 10-kernel-hardening.conf, 10-magic-sysrq.conf, 10-map-count.conf, 10-network-security.conf, 10-ptrace.conf, 10-zeropage.conf, 30-brave.conf, 99-dev-limits.conf - -#### ✅ `/etc/security/limits.conf` -- **Status**: Only nofile limits present (16777216) - no conflicts -- **Settings**: - ``` - * soft nofile 16777216 - * hard nofile 16777216 - ``` - -#### ✅ `/etc/systemd/system.conf` & `/etc/systemd/user.conf` -- **Status**: Default systemd limits commented out - no conflicts -- **Settings**: All DefaultLimit* entries are commented out (using defaults) - -### Verification Results ✅ - -```bash -# Applied limits verified: -fs.inotify.max_user_instances = 2048 ✅ (was 128) -fs.inotify.max_queued_events = 65536 ✅ (was 16384) - -# Unchanged (already adequate): -fs.inotify.max_user_watches = 1009491 -``` - -### Memory Impact -- **Before**: 128 instances × ~1KB = ~128KB -- **After**: 2048 instances × ~1KB = ~2MB -- **Impact**: Negligible on 124GB system - -### Next Steps -- [x] Test `journalctl -f` functionality -- [x] Monitor webpack watch mode stability -- [x] No reboot required (changes applied via `sysctl --system`) - ---- - -**Status**: ✅ COMPLETED - All limits successfully applied and verified \ No newline at end of file diff --git a/tests/empty/anything b/tests/empty/anything deleted file mode 100644 index e69de29..0000000 From 0cefe44372a1b7297348ee11ae2118d82cc690bd Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Wed, 28 May 2025 11:14:04 -0500 Subject: [PATCH 10/12] Add CONTRIBUTING.md --- CONTRIBUTING.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 CONTRIBUTING.md 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 From 37ff24dadda1d7b4a0e360f191c4ebc49141cb0a Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 12 Jun 2025 15:21:46 -0500 Subject: [PATCH 11/12] fix: redirect warnings and errors to stderr --- rstring/cli.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/rstring/cli.py b/rstring/cli.py index 2ed9ff4..f48a6dc 100644 --- a/rstring/cli.py +++ b/rstring/cli.py @@ -1,6 +1,7 @@ import argparse import logging import os +import sys from .utils import ( check_rsync, run_rsync, validate_rsync_args, @@ -57,7 +58,7 @@ def get_default_patterns(): 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( @@ -98,12 +99,12 @@ def main(): else: target_dir, rsync_args_base = parse_target_directory(unknown_args) except ValueError as e: - print(f"Error: {e}") + print(f"Error: {e}", file=sys.stderr) return # Validate target directory exists if not os.path.isdir(target_dir): - print(f"Error: Directory '{target_dir}' does not exist.") + print(f"Error: Directory '{target_dir}' does not exist.", file=sys.stderr) return # Use provided patterns or conservative default @@ -119,7 +120,7 @@ def main(): gitignore_patterns = parse_gitignore(gitignore_path) rsync_args = gitignore_patterns + rsync_args else: - print(f"Warning: No .gitignore file found in {target_dir}. 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('--')): @@ -131,7 +132,7 @@ def main(): os.chdir(target_dir) if not validate_rsync_args(rsync_args): - print("Error: Invalid rsync arguments. Please check and try again.") + print("Error: Invalid rsync arguments. Please check and try again.", file=sys.stderr) return if args.interactive: From ea8043ebc4b393521172f19a8788f67e0bd9c293 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 12 Jun 2025 18:44:49 -0500 Subject: [PATCH 12/12] Fixes --- rstring/cli.py | 17 +++++++++++++---- rstring/utils.py | 17 +++++++++-------- tests/test_rstring.py | 41 ++++++++++++++++++++++++++++++----------- 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/rstring/cli.py b/rstring/cli.py index f48a6dc..c58659a 100644 --- a/rstring/cli.py +++ b/rstring/cli.py @@ -156,10 +156,15 @@ def main(): 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] @@ -174,9 +179,13 @@ 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" + 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 "" - print(f"{action}{target_info}") + if 'RSTRING_TESTING' not in os.environ: + print(f"{action}{target_info}") if __name__ == "__main__": diff --git a/rstring/utils.py b/rstring/utils.py index e3519b6..82e9d09 100644 --- a/rstring/utils.py +++ b/rstring/utils.py @@ -4,6 +4,7 @@ import platform import shlex import subprocess +import sys logger = logging.getLogger(__name__) @@ -110,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']: @@ -140,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 diff --git a/tests/test_rstring.py b/tests/test_rstring.py index 93e055a..6febc0b 100644 --- a/tests/test_rstring.py +++ b/tests/test_rstring.py @@ -84,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(): @@ -165,9 +166,10 @@ def test_main_with_default_patterns(): 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('sys.argv', ['rstring']): - cli.main() - mock_copy.assert_called_once_with(mock_gathered_code) + 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') @@ -192,12 +194,13 @@ def test_main_with_target_directory(mock_check_rsync, mock_get_tree_string, f.write('print("test")') with patch('sys.argv', ['rstring', '-C', temp_dir, '--include=*.py']): - with patch('os.chdir') as mock_chdir: - cli.main() + with patch.dict(os.environ, {'RSTRING_TESTING': 'True'}): + with patch('os.chdir') as mock_chdir: + cli.main() - # Should change to target directory and back - assert mock_chdir.call_count == 2 - mock_copy_to_clipboard.assert_called_once_with('test content') + # 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(): @@ -206,7 +209,23 @@ def test_main_with_nonexistent_directory(): 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.") + 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():