janito 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. janito/__init__.py +48 -1
  2. janito/__main__.py +29 -235
  3. janito/_contextparser.py +113 -0
  4. janito/agents/__init__.py +22 -0
  5. janito/agents/agent.py +21 -0
  6. janito/agents/claudeai.py +64 -0
  7. janito/agents/openai.py +53 -0
  8. janito/agents/test.py +34 -0
  9. janito/analysis/__init__.py +33 -0
  10. janito/analysis/display.py +149 -0
  11. janito/analysis/options.py +112 -0
  12. janito/analysis/prompts.py +75 -0
  13. janito/change/__init__.py +19 -0
  14. janito/change/applier.py +269 -0
  15. janito/change/content.py +62 -0
  16. janito/change/indentation.py +33 -0
  17. janito/change/position.py +169 -0
  18. janito/changehistory.py +46 -0
  19. janito/changeviewer/__init__.py +12 -0
  20. janito/changeviewer/diff.py +28 -0
  21. janito/changeviewer/panels.py +268 -0
  22. janito/changeviewer/styling.py +59 -0
  23. janito/changeviewer/themes.py +57 -0
  24. janito/cli/__init__.py +2 -0
  25. janito/cli/commands.py +53 -0
  26. janito/cli/functions.py +286 -0
  27. janito/cli/registry.py +26 -0
  28. janito/common.py +23 -0
  29. janito/config.py +8 -3
  30. janito/console/__init__.py +3 -0
  31. janito/console/commands.py +112 -0
  32. janito/console/core.py +62 -0
  33. janito/console/display.py +157 -0
  34. janito/fileparser.py +334 -0
  35. janito/prompts.py +58 -74
  36. janito/qa.py +40 -7
  37. janito/review.py +13 -0
  38. janito/scan.py +68 -14
  39. janito/tests/test_fileparser.py +26 -0
  40. janito/version.py +23 -0
  41. janito-0.5.0.dist-info/METADATA +146 -0
  42. janito-0.5.0.dist-info/RECORD +45 -0
  43. janito/changeviewer.py +0 -64
  44. janito/claude.py +0 -74
  45. janito/console.py +0 -60
  46. janito/contentchange.py +0 -165
  47. janito-0.3.0.dist-info/METADATA +0 -138
  48. janito-0.3.0.dist-info/RECORD +0 -15
  49. {janito-0.3.0.dist-info → janito-0.5.0.dist-info}/WHEEL +0 -0
  50. {janito-0.3.0.dist-info → janito-0.5.0.dist-info}/entry_points.txt +0 -0
  51. {janito-0.3.0.dist-info → janito-0.5.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,33 @@
1
+ """Analysis module for Janito.
2
+
3
+ This module provides functionality for analyzing and displaying code changes.
4
+ """
5
+
6
+ from .options import AnalysisOption, parse_analysis_options
7
+ from .display import (
8
+ format_analysis,
9
+ get_history_file_type,
10
+ get_history_path,
11
+ get_timestamp,
12
+ save_to_file
13
+ )
14
+ from .prompts import (
15
+ build_request_analysis_prompt,
16
+ get_option_selection,
17
+ prompt_user,
18
+ validate_option_letter
19
+ )
20
+
21
+ __all__ = [
22
+ 'AnalysisOption',
23
+ 'parse_analysis_options',
24
+ 'format_analysis',
25
+ 'get_history_file_type',
26
+ 'get_history_path',
27
+ 'get_timestamp',
28
+ 'save_to_file',
29
+ 'build_request_analysis_prompt',
30
+ 'get_option_selection',
31
+ 'prompt_user',
32
+ 'validate_option_letter'
33
+ ]
@@ -0,0 +1,149 @@
1
+ """Display formatting for analysis results."""
2
+
3
+ from typing import Optional, Dict
4
+ from pathlib import Path
5
+ from datetime import datetime, timezone
6
+ from rich.console import Console
7
+ from rich.markdown import Markdown
8
+ from rich.panel import Panel
9
+ from rich.text import Text
10
+ from rich import box
11
+ from rich.columns import Columns
12
+ from rich.rule import Rule
13
+ from janito.agents import AIAgent, AgentSingleton
14
+ from .options import AnalysisOption
15
+ from .options import parse_analysis_options
16
+
17
+ MIN_PANEL_WIDTH = 40
18
+
19
+ def get_analysis_summary(options: Dict[str, AnalysisOption]) -> str:
20
+ """Generate a summary of affected directories and their file counts."""
21
+ dirs_summary = {}
22
+ for _, option in options.items():
23
+ for file in option.affected_files:
24
+ clean_path = option.get_clean_path(file)
25
+ dir_path = str(Path(clean_path).parent)
26
+ dirs_summary[dir_path] = dirs_summary.get(dir_path, 0) + 1
27
+
28
+ return " | ".join([f"{dir}: {count} files" for dir, count in dirs_summary.items()])
29
+
30
+ def _display_options(options: Dict[str, AnalysisOption]) -> None:
31
+ """Display available options in a single horizontal row with equal widths."""
32
+ console = Console()
33
+
34
+ console.print()
35
+ console.print(Rule(" Available Options ", style="bold cyan", align="center"))
36
+ console.print()
37
+
38
+ term_width = console.width or 100
39
+ spacing = 4
40
+ total_spacing = spacing * (len(options) - 1)
41
+ panel_width = max(MIN_PANEL_WIDTH, (term_width - total_spacing) // len(options))
42
+
43
+ panels = []
44
+ for letter, option in options.items():
45
+ content = Text()
46
+
47
+ content.append("Description:\n", style="bold cyan")
48
+ for item in option.description_items:
49
+ content.append(f"• {item}\n", style="white")
50
+ content.append("\n")
51
+
52
+ if option.affected_files:
53
+ content.append("Affected files:\n", style="bold cyan")
54
+ unique_files = {}
55
+ for file in option.affected_files:
56
+ clean_path = option.get_clean_path(file)
57
+ unique_files[clean_path] = file
58
+
59
+ for file in unique_files.values():
60
+ if '(new)' in file:
61
+ color = "green"
62
+ elif '(removed)' in file:
63
+ color = "red"
64
+ else:
65
+ color = "yellow"
66
+ content.append(f"• {file}\n", style=color)
67
+
68
+ panel = Panel(
69
+ content,
70
+ box=box.ROUNDED,
71
+ border_style="cyan",
72
+ title=f"Option {letter}: {option.summary}",
73
+ title_align="center",
74
+ padding=(1, 2),
75
+ width=panel_width
76
+ )
77
+ panels.append(panel)
78
+
79
+ if panels:
80
+ columns = Columns(
81
+ panels,
82
+ align="center",
83
+ expand=True,
84
+ equal=True,
85
+ padding=(0, spacing // 2)
86
+ )
87
+ console.print(columns)
88
+
89
+ def _display_markdown(content: str) -> None:
90
+ """Display content in markdown format."""
91
+ console = Console()
92
+ md = Markdown(content)
93
+ console.print(md)
94
+
95
+ def _display_raw_history(agent: AIAgent) -> None:
96
+ """Display raw message history from Claude agent."""
97
+ console = Console()
98
+ console.print("\n=== Message History ===")
99
+ for role, content in agent.messages_history:
100
+ console.print(f"\n[bold cyan]{role.upper()}:[/bold cyan]")
101
+ console.print(content)
102
+ console.print("\n=== End Message History ===\n")
103
+
104
+ def format_analysis(analysis: str, raw: bool = False, workdir: Optional[Path] = None) -> None:
105
+ """Format and display the analysis output with enhanced capabilities."""
106
+ console = Console()
107
+
108
+ agent = AgentSingleton.get_agent()
109
+ if raw and agent:
110
+ _display_raw_history(agent)
111
+ else:
112
+ options = parse_analysis_options(analysis)
113
+ if options:
114
+ _display_options(options)
115
+ else:
116
+ console.print("\n[yellow]Warning: No valid options found in response. Displaying as markdown.[/yellow]\n")
117
+ _display_markdown(analysis)
118
+
119
+ def get_history_file_type(filepath: Path) -> str:
120
+ """Determine the type of saved file based on its name"""
121
+ name = filepath.name.lower()
122
+ if 'changes' in name:
123
+ return 'changes'
124
+ elif 'selected' in name:
125
+ return 'selected'
126
+ elif 'analysis' in name:
127
+ return 'analysis'
128
+ elif 'response' in name:
129
+ return 'response'
130
+ return 'unknown'
131
+
132
+ def get_history_path(workdir: Path) -> Path:
133
+ """Create and return the history directory path"""
134
+ history_dir = workdir / '.janito' / 'history'
135
+ history_dir.mkdir(parents=True, exist_ok=True)
136
+ return history_dir
137
+
138
+ def get_timestamp() -> str:
139
+ """Get current UTC timestamp in YMD_HMS format with leading zeros"""
140
+ return datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
141
+
142
+ def save_to_file(content: str, prefix: str, workdir: Path) -> Path:
143
+ """Save content to a timestamped file in history directory"""
144
+ history_dir = get_history_path(workdir)
145
+ timestamp = get_timestamp()
146
+ filename = f"{timestamp}_{prefix}.txt"
147
+ file_path = history_dir / filename
148
+ file_path.write_text(content)
149
+ return file_path
@@ -0,0 +1,112 @@
1
+ """Options handling for analysis module."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import List, Dict, Tuple
6
+ import re
7
+
8
+ @dataclass
9
+ class AnalysisOption:
10
+ letter: str
11
+ summary: str
12
+ affected_files: List[str]
13
+ description_items: List[str]
14
+
15
+ def get_clean_path(self, file_path: str) -> str:
16
+ """Get clean path without markers"""
17
+ return file_path.split(' (')[0].strip()
18
+
19
+ def is_new_file(self, file_path: str) -> bool:
20
+ """Check if file is marked as new"""
21
+ return '(new)' in file_path
22
+
23
+ def is_removed_file(self, file_path: str) -> bool:
24
+ """Check if file is marked as removed"""
25
+ return '(removed)' in file_path
26
+
27
+ def get_affected_paths(self, workdir: Path = None) -> List[Path]:
28
+ """Get list of affected paths, resolving against workdir if provided"""
29
+ paths = []
30
+ for file_path in self.affected_files:
31
+ clean_path = self.get_clean_path(file_path)
32
+ path = workdir / clean_path if workdir else Path(clean_path)
33
+ paths.append(path)
34
+ return paths
35
+
36
+ def process_file_path(self, path: str) -> Tuple[str, bool, bool, bool]:
37
+ """Process a file path to extract clean path and modification flags
38
+ Returns: (clean_path, is_new, is_modified, is_removed)
39
+ """
40
+ clean_path = path.strip()
41
+ is_new = False
42
+ is_modified = False
43
+ is_removed = False
44
+
45
+ if "(new)" in clean_path:
46
+ is_new = True
47
+ clean_path = clean_path.replace("(new)", "").strip()
48
+ if "(modified)" in clean_path:
49
+ is_modified = True
50
+ clean_path = clean_path.replace("(modified)", "").strip()
51
+ if "(removed)" in clean_path:
52
+ is_removed = True
53
+ clean_path = clean_path.replace("(removed)", "").strip()
54
+
55
+ return clean_path, is_new, is_modified, is_removed
56
+
57
+ def parse_analysis_options(response: str) -> Dict[str, AnalysisOption]:
58
+ """Parse options from the response text."""
59
+ options = {}
60
+
61
+ if 'END_OF_OPTIONS' in response:
62
+ response = response.split('END_OF_OPTIONS')[0]
63
+
64
+ current_option = None
65
+ current_section = None
66
+
67
+ lines = response.split('\n')
68
+
69
+ for line in lines:
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+
74
+ option_match = re.match(r'^([A-Z])\.\s+(.+)$', line)
75
+ if option_match:
76
+ if current_option:
77
+ options[current_option.letter] = current_option
78
+
79
+ letter, summary = option_match.groups()
80
+ current_option = AnalysisOption(
81
+ letter=letter,
82
+ summary=summary,
83
+ affected_files=[],
84
+ description_items=[]
85
+ )
86
+ current_section = None
87
+ continue
88
+
89
+ if re.match(r'^-+$', line):
90
+ continue
91
+
92
+ if current_option:
93
+ if line.lower() == 'description:':
94
+ current_section = 'description'
95
+ continue
96
+ elif line.lower() == 'affected files:':
97
+ current_section = 'files'
98
+ continue
99
+
100
+ if line.startswith('- '):
101
+ content = line[2:].strip()
102
+ if current_section == 'description':
103
+ current_option.description_items.append(content)
104
+ elif current_section == 'files':
105
+ # Accept any combination of new, modified or removed markers
106
+ if any(marker in content for marker in ['(new)', '(modified)', '(removed)']):
107
+ current_option.affected_files.append(content)
108
+
109
+ if current_option:
110
+ options[current_option.letter] = current_option
111
+
112
+ return options
@@ -0,0 +1,75 @@
1
+ """User prompts and input handling for analysis."""
2
+
3
+ from typing import List
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.rule import Rule
7
+ from rich.prompt import Prompt
8
+ from rich import box
9
+
10
+ # Keep only prompt-related functionality
11
+ CHANGE_ANALYSIS_PROMPT = """
12
+ Current files:
13
+ <files>
14
+ {files_content}
15
+ </files>
16
+
17
+ Considering the above current files content, provide 3 sections, each identified by a keyword and representing an option.
18
+ Each option should include a concise description and a list of affected files.
19
+ 1st option should be minimalistic style change, 2nd organized style, 3rd exntensible style.
20
+ Do not use style as keyword, instead focus on the changes summaru
21
+ Use the following format:
22
+
23
+ A. Keyword summary of the change
24
+ -----------------
25
+ Description:
26
+ - Concise description of the change
27
+
28
+ Affected files:
29
+ - path/file1.py (new)
30
+ - path/file2.py (modified)
31
+ - path/file3.py (removed)
32
+
33
+ END_OF_OPTIONS (mandatory marker)
34
+
35
+ RULES:
36
+ - do NOT provide the content of the files
37
+ - do NOT offer to implement the changes
38
+ - description items should be 80 chars or less
39
+
40
+ Request:
41
+ {request}
42
+ """
43
+
44
+ def prompt_user(message: str, choices: List[str] = None) -> str:
45
+ """Display a prominent user prompt with optional choices"""
46
+ console = Console()
47
+ console.print()
48
+ console.print(Rule(" User Input Required ", style="bold cyan"))
49
+
50
+ if choices:
51
+ choice_text = f"[cyan]Options: {', '.join(choices)}[/cyan]"
52
+ console.print(Panel(choice_text, box=box.ROUNDED))
53
+
54
+ return Prompt.ask(f"[bold cyan]> {message}[/bold cyan]")
55
+
56
+ def validate_option_letter(letter: str, options: dict) -> bool:
57
+ """Validate if the given letter is a valid option or 'M' for modify"""
58
+ return letter.upper() in options or letter.upper() == 'M'
59
+
60
+ def get_option_selection() -> str:
61
+ """Get user input for option selection with modify option"""
62
+ console = Console()
63
+ console.print("\n[cyan]Enter option letter or 'M' to modify request[/cyan]")
64
+ while True:
65
+ letter = prompt_user("Select option").strip().upper()
66
+ if letter == 'M' or (letter.isalpha() and len(letter) == 1):
67
+ return letter
68
+ console.print("[red]Please enter a valid letter or 'M'[/red]")
69
+
70
+ def build_request_analysis_prompt(files_content: str, request: str) -> str:
71
+ """Build prompt for information requests"""
72
+ return CHANGE_ANALYSIS_PROMPT.format(
73
+ files_content=files_content,
74
+ request=request
75
+ )
@@ -0,0 +1,19 @@
1
+ from .applier import apply_single_change
2
+ from .position import parse_and_apply_changes_sequence
3
+ from .content import (
4
+ get_file_type,
5
+ process_and_save_changes,
6
+ format_parsed_changes,
7
+ apply_content_changes,
8
+ handle_changes_file
9
+ )
10
+
11
+ __all__ = [
12
+ 'apply_single_change',
13
+ 'parse_and_apply_changes_sequence',
14
+ 'get_file_type',
15
+ 'process_and_save_changes',
16
+ 'format_parsed_changes',
17
+ 'apply_content_changes',
18
+ 'handle_changes_file'
19
+ ]
@@ -0,0 +1,269 @@
1
+ from pathlib import Path
2
+ from typing import Tuple, Optional, Set
3
+ from rich.console import Console
4
+ from rich import box
5
+ from rich.panel import Panel
6
+ from rich.prompt import Confirm
7
+ from datetime import datetime
8
+ import subprocess
9
+ import shutil
10
+ import tempfile
11
+
12
+ from janito.fileparser import FileChange
13
+ from janito.config import config
14
+ from .position import find_text_positions, format_whitespace_debug
15
+ from .indentation import adjust_indentation
16
+ from typing import List
17
+ from ..changeviewer import preview_all_changes
18
+ from ..fileparser import validate_python_syntax
19
+ from ..changehistory import get_history_file_path
20
+
21
+
22
+ def run_test_command(preview_dir: Path, test_cmd: str) -> Tuple[bool, str, Optional[str]]:
23
+ """Run test command in preview directory.
24
+ Returns (success, output, error)"""
25
+ try:
26
+ result = subprocess.run(
27
+ test_cmd,
28
+ shell=True,
29
+ cwd=preview_dir,
30
+ capture_output=True,
31
+ text=True,
32
+ timeout=300 # 5 minute timeout
33
+ )
34
+ return (
35
+ result.returncode == 0,
36
+ result.stdout,
37
+ result.stderr if result.returncode != 0 else None
38
+ )
39
+ except subprocess.TimeoutExpired:
40
+ return False, "", "Test command timed out after 5 minutes"
41
+ except Exception as e:
42
+ return False, "", f"Error running test: {str(e)}"
43
+
44
+ def preview_and_apply_changes(changes: List[FileChange], workdir: Path, test_cmd: str = None) -> bool:
45
+ """Preview changes and apply if confirmed"""
46
+ console = Console()
47
+
48
+ if not changes:
49
+ console.print("\n[yellow]No changes were found to apply[/yellow]")
50
+ return False
51
+
52
+ # Show change preview
53
+ preview_all_changes(console, changes)
54
+
55
+ with tempfile.TemporaryDirectory() as temp_dir:
56
+ preview_dir = Path(temp_dir)
57
+ console.print("\n[blue]Creating preview in temporary directory...[/blue]")
58
+
59
+ # Create backup directory
60
+ backup_dir = workdir / '.janito' / 'backups' / datetime.now().strftime('%Y%m%d_%H%M%S')
61
+ backup_dir.parent.mkdir(parents=True, exist_ok=True)
62
+
63
+ # Copy existing files to preview directory
64
+ if workdir.exists():
65
+ if config.verbose:
66
+ console.print(f"[blue]Creating backup at:[/blue] {backup_dir}")
67
+ shutil.copytree(workdir, backup_dir, ignore=shutil.ignore_patterns('.janito'))
68
+ shutil.copytree(workdir, preview_dir, dirs_exist_ok=True, ignore=shutil.ignore_patterns('.janito'))
69
+
70
+ # Create restore script
71
+ restore_script = workdir / '.janito' / 'restore.sh'
72
+ restore_script.parent.mkdir(parents=True, exist_ok=True)
73
+ script_content = f"""#!/bin/bash
74
+ # Restore script generated by Janito
75
+ # Restores files from backup created at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
76
+
77
+ # Exit on error
78
+ set -e
79
+
80
+ # Check if backup directory exists
81
+ if [ ! -d "{backup_dir}" ]; then
82
+ echo "Error: Backup directory not found at {backup_dir}"
83
+ exit 1
84
+ fi
85
+
86
+ # Restore files from backup
87
+ echo "Restoring files from backup..."
88
+ cp -r "{backup_dir}"/* "{workdir}/"
89
+
90
+ echo "Files restored successfully from {backup_dir}"
91
+ """
92
+ restore_script.write_text(script_content)
93
+ restore_script.chmod(0o755)
94
+
95
+ if config.verbose:
96
+ console.print(f"[blue]Created restore script at:[/blue] {restore_script}")
97
+
98
+ # Track modified files and apply changes to preview directory
99
+ modified_files: Set[Path] = set()
100
+ any_errors = False
101
+ for change in changes:
102
+ if config.verbose:
103
+ console.print(f"[dim]Previewing changes for {change.path}...[/dim]")
104
+ success, error = apply_single_change(change.path, change, workdir, preview_dir)
105
+ if success and not change.remove_file:
106
+ modified_files.add(change.path)
107
+ if not success:
108
+ if "file already exists" in str(error):
109
+ console.print(f"\n[red]Error: Cannot create {change.path}[/red]")
110
+ console.print("[red]File already exists and overwriting is not allowed.[/red]")
111
+ else:
112
+ console.print(f"\n[red]Error previewing changes for {change.path}:[/red]")
113
+ console.print(f"[red]{error}[/red]")
114
+ any_errors = True
115
+ continue
116
+
117
+ if any_errors:
118
+ console.print("\n[red]Some changes could not be previewed. Aborting.[/red]")
119
+ return False
120
+
121
+ # Validate Python syntax
122
+ python_files = {change.path for change in changes if change.path.suffix == '.py'}
123
+ for filepath in python_files:
124
+ preview_path = preview_dir / filepath
125
+ is_valid, error_msg = validate_python_syntax(preview_path.read_text(), preview_path)
126
+ if not is_valid:
127
+ console.print(f"\n[red]Python syntax validation failed for {filepath}:[/red]")
128
+ console.print(f"[red]{error_msg}[/red]")
129
+ return False
130
+
131
+ # Run tests if specified
132
+ if test_cmd:
133
+ console.print(f"\n[cyan]Testing changes in preview directory:[/cyan] {test_cmd}")
134
+ success, output, error = run_test_command(preview_dir, test_cmd)
135
+
136
+ if output:
137
+ console.print("\n[bold]Test Output:[/bold]")
138
+ console.print(Panel(output, box=box.ROUNDED))
139
+
140
+ if not success:
141
+ console.print("\n[red bold]Tests failed in preview. Changes will not be applied.[/red bold]")
142
+ if error:
143
+ console.print(Panel(error, title="Error", border_style="red"))
144
+ return False
145
+
146
+ # Final confirmation
147
+ if not Confirm.ask("\n[cyan bold]Apply previewed changes to working directory?[/cyan bold]"):
148
+ console.print("\n[yellow]Changes were only previewed, not applied to working directory[/yellow]")
149
+ console.print("[green]Changes are stored in the history directory and can be applied later using:[/green]")
150
+ changes_file = get_history_file_path(workdir)
151
+ console.print(f"[cyan] {changes_file.relative_to(workdir)}[/cyan]")
152
+ return False
153
+
154
+ # Apply changes - copy each modified file only once
155
+ console.print("\n[blue]Applying changes to working directory...[/blue]")
156
+ for file_path in modified_files:
157
+ console.print(f"[dim]Applying changes to {file_path}...[/dim]")
158
+ target_path = workdir / file_path
159
+ preview_path = preview_dir / file_path
160
+ target_path.parent.mkdir(parents=True, exist_ok=True)
161
+ shutil.copy2(preview_path, target_path)
162
+
163
+ # Handle file removals separately
164
+ for change in changes:
165
+ if change.remove_file:
166
+ target_path = workdir / change.path
167
+ if target_path.exists():
168
+ target_path.unlink()
169
+ console.print(f"[red]Removed {change.path}[/red]")
170
+
171
+ console.print("\n[green]Changes successfully applied to working directory![/green]")
172
+ return True
173
+
174
+ def apply_single_change(filepath: Path, change: FileChange, workdir: Path, preview_dir: Path) -> Tuple[bool, Optional[str]]:
175
+ """Apply a single file change"""
176
+ preview_path = preview_dir / filepath
177
+ preview_path.parent.mkdir(parents=True, exist_ok=True)
178
+
179
+ if change.remove_file:
180
+ orig_path = workdir / filepath
181
+ if not orig_path.exists():
182
+ return False, f"Cannot remove non-existent file {filepath}"
183
+ if config.debug:
184
+ console = Console()
185
+ console.print(f"\n[red]Removing file {filepath}[/red]")
186
+ # For preview, we don't create the file in preview dir
187
+ return True, None
188
+
189
+ if config.debug:
190
+ console = Console()
191
+ console.print(f"\n[cyan]Processing change for {filepath}[/cyan]")
192
+ console.print(f"[dim]Change type: {'new file' if change.is_new_file else 'modification'}[/dim]")
193
+
194
+ if change.is_new_file or change.replace_file:
195
+ if change.is_new_file and filepath.exists():
196
+ return False, "Cannot create file - already exists"
197
+ if config.debug:
198
+ action = "Creating new" if change.is_new_file else "Replacing"
199
+ console.print(f"[cyan]{action} file with content:[/cyan]")
200
+ console.print(Panel(change.content, title="File Content"))
201
+ preview_path.write_text(change.content)
202
+ return True, None
203
+
204
+ orig_path = workdir / filepath
205
+ if not orig_path.exists():
206
+ return False, f"Cannot modify non-existent file {filepath}"
207
+
208
+ content = orig_path.read_text()
209
+ modified = content
210
+
211
+ for search, replace, description in change.search_blocks:
212
+ if config.debug:
213
+ console.print(f"\n[cyan]Processing search block:[/cyan] {description or 'no description'}")
214
+ console.print("[yellow]Search text:[/yellow]")
215
+ console.print(Panel(format_whitespace_debug(search)))
216
+ if replace is not None:
217
+ console.print("[yellow]Replace with:[/yellow]")
218
+ console.print(Panel(format_whitespace_debug(replace)))
219
+ else:
220
+ console.print("[yellow]Action:[/yellow] Delete text")
221
+
222
+ positions = find_text_positions(modified, search)
223
+
224
+ if config.debug:
225
+ console.print(f"[cyan]Found {len(positions)} matches[/cyan]")
226
+
227
+ if not positions:
228
+ error_context = f" ({description})" if description else ""
229
+ debug_search = format_whitespace_debug(search)
230
+ debug_content = format_whitespace_debug(modified)
231
+ error_msg = (
232
+ f"Could not find search text in {filepath}{error_context}:\n\n"
233
+ f"[yellow]Search text (with whitespace markers):[/yellow]\n"
234
+ f"{debug_search}\n\n"
235
+ f"[yellow]File content (with whitespace markers):[/yellow]\n"
236
+ f"{debug_content}"
237
+ )
238
+ return False, error_msg
239
+
240
+ # Apply replacements from end to start to maintain position validity
241
+ for start, end in reversed(positions):
242
+ if config.debug:
243
+ console.print(f"\n[cyan]Replacing text at positions {start}-{end}:[/cyan]")
244
+ console.print("[yellow]Original segment:[/yellow]")
245
+ console.print(Panel(format_whitespace_debug(modified[start:end])))
246
+ if replace is not None:
247
+ console.print("[yellow]Replacing with:[/yellow]")
248
+ console.print(Panel(format_whitespace_debug(replace)))
249
+
250
+ # Adjust replacement text indentation
251
+ original_segment = modified[start:end]
252
+ adjusted_replace = adjust_indentation(original_segment, replace) if replace else ""
253
+
254
+ if config.debug and replace:
255
+ console.print("[yellow]Adjusted replacement:[/yellow]")
256
+ console.print(Panel(format_whitespace_debug(adjusted_replace)))
257
+
258
+ modified = modified[:start] + adjusted_replace + modified[end:]
259
+
260
+ if modified == content:
261
+ if config.debug:
262
+ console.print("\n[yellow]No changes were applied to the file[/yellow]")
263
+ return False, "No changes were applied"
264
+
265
+ if config.debug:
266
+ console.print("\n[green]Changes applied successfully[/green]")
267
+
268
+ preview_path.write_text(modified)
269
+ return True, None