Skip to main content

tycode_core/file/modify/
apply_codex_patch.rs

1use crate::chat::events::{ToolExecutionResult, ToolRequest as ToolRequestEvent, ToolRequestType};
2use crate::file::access::FileAccessManager;
3use crate::file::find::find_closest_match;
4use crate::file::manager::FileModificationManager;
5use crate::tools::r#trait::{
6    ContinuationPreference, FileModification, FileOperation, ToolCallHandle, ToolCategory,
7    ToolExecutor, ToolOutput, ToolRequest,
8};
9use anyhow::{bail, Result};
10use serde_json::{json, Value};
11use std::path::PathBuf;
12
13/// Tool for applying codex-style patches without line numbers
14#[derive(Clone)]
15pub struct ApplyCodexPatchTool {
16    file_manager: FileAccessManager,
17}
18
19#[derive(Debug, Clone, PartialEq)]
20enum CodexHunkLine {
21    Context(String),
22    Removal(String),
23    Addition(String),
24}
25
26impl CodexHunkLine {
27    pub fn patch(&self) -> String {
28        match self {
29            CodexHunkLine::Context(s) => format!(" {s}"),
30            CodexHunkLine::Removal(s) => format!("-{s}"),
31            CodexHunkLine::Addition(s) => format!("+{s}"),
32        }
33    }
34}
35
36#[derive(Debug)]
37struct CodexHunk {
38    lines: Vec<CodexHunkLine>,
39}
40
41impl CodexHunk {
42    pub fn patch(&self) -> String {
43        let mut result = String::new();
44        for line in &self.lines {
45            result = format!("{result}{}\n", line.patch());
46        }
47        result
48    }
49}
50
51impl ApplyCodexPatchTool {
52    pub fn new(workspace_roots: Vec<PathBuf>) -> anyhow::Result<Self> {
53        let file_manager = FileAccessManager::new(workspace_roots)?;
54        Ok(Self { file_manager })
55    }
56
57    /// Strip leading and trailing @@ markers from a hunk string.
58    fn strip_leading_trailing_markers(&self, hunk_str: &str) -> String {
59        let lines: Vec<&str> = hunk_str.lines().collect();
60        let mut start = 0;
61        let mut end = lines.len();
62
63        while start < end && lines[start].trim_start().starts_with("@@") {
64            start += 1;
65        }
66
67        while end > start && lines[end - 1].trim_start().starts_with("@@") {
68            end -= 1;
69        }
70
71        lines[start..end].join("\n")
72    }
73
74    /// AI models sometimes incorrectly concatenate multiple hunks into a single string.
75    /// This silently fixes such errors to improve usability without advertising the capability.
76    fn split_hunks_on_markers(&self, hunks: &[String]) -> Vec<String> {
77        hunks
78            .iter()
79            .flat_map(|hunk| self.split_single_hunk(hunk))
80            .collect()
81    }
82
83    fn split_single_hunk(&self, hunk: &str) -> Vec<String> {
84        let lines: Vec<&str> = hunk.lines().collect();
85        let mut result = Vec::new();
86        let mut current_hunk_lines = Vec::new();
87        let mut seen_content = false;
88
89        for line in lines {
90            let is_marker = line.trim_start().starts_with("@@");
91
92            if is_marker && !seen_content {
93                continue;
94            }
95
96            if is_marker && seen_content {
97                if !current_hunk_lines.is_empty() {
98                    result.push(current_hunk_lines.join("\n"));
99                    current_hunk_lines.clear();
100                }
101                seen_content = false;
102                continue;
103            }
104
105            current_hunk_lines.push(line);
106            seen_content = true;
107        }
108
109        if !current_hunk_lines.is_empty() {
110            result.push(current_hunk_lines.join("\n"));
111        }
112        result
113    }
114
115    /// Parse a single hunk from a string.
116    fn parse_single_hunk(&self, hunk_str: &str) -> Result<CodexHunk> {
117        let cleaned = self.strip_leading_trailing_markers(hunk_str);
118        let lines: Vec<&str> = cleaned.lines().collect();
119        let mut hunk_lines = Vec::new();
120
121        for line in lines {
122            if line.starts_with("-") {
123                hunk_lines.push(CodexHunkLine::Removal(line[1..].to_string()));
124            } else if line.starts_with("+") {
125                hunk_lines.push(CodexHunkLine::Addition(line[1..].to_string()));
126            } else if line.starts_with(" ") {
127                hunk_lines.push(CodexHunkLine::Context(line[1..].to_string()));
128            } else if line.is_empty() {
129                hunk_lines.push(CodexHunkLine::Context(String::new()));
130            } else {
131                hunk_lines.push(CodexHunkLine::Context(line.to_string()));
132            }
133        }
134
135        while let Some(CodexHunkLine::Context(content)) = hunk_lines.first() {
136            if !content.trim().is_empty() {
137                break;
138            }
139            hunk_lines.remove(0);
140        }
141
142        while let Some(CodexHunkLine::Context(content)) = hunk_lines.last() {
143            if !content.trim().is_empty() {
144                break;
145            }
146            hunk_lines.pop();
147        }
148
149        let has_changes = hunk_lines
150            .iter()
151            .any(|line| matches!(line, CodexHunkLine::Removal(_) | CodexHunkLine::Addition(_)));
152
153        if !has_changes {
154            bail!("Hunk must contain at least one addition (+ line) or removal (- line)");
155        }
156
157        Ok(CodexHunk { lines: hunk_lines })
158    }
159
160    /// Find the position where a hunk should be applied
161    fn find_hunk_position(&self, file_lines: &[String], hunk: &CodexHunk) -> Result<usize> {
162        let expected_original: Vec<String> = hunk
163            .lines
164            .iter()
165            .filter_map(|line| match line {
166                CodexHunkLine::Context(content) => Some(content.clone()),
167                CodexHunkLine::Removal(content) => Some(content.clone()),
168                CodexHunkLine::Addition(_) => None,
169            })
170            .collect();
171
172        if expected_original.is_empty() {
173            bail!(
174                "Hunk must contain some original content to match: \n{}",
175                hunk.patch()
176            );
177        }
178
179        let mut matches = Vec::new();
180        for start_idx in 0..=file_lines.len().saturating_sub(expected_original.len()) {
181            if self.hunk_matches_at(file_lines, start_idx, &expected_original) {
182                matches.push(start_idx);
183            }
184        }
185
186        match matches.len() {
187            0 => {
188                let closest_match =
189                    find_closest_match(file_lines.to_vec(), expected_original.clone());
190
191                if let Some(closest) = closest_match {
192                    bail!(
193                        "Could not find matching content for hunk in file. {}\n\nTip: ensure you are tracking the file (set_tracked_files tool) to give see the latest contents of the file.",
194                        closest.get_correction_feedback().unwrap(),
195                    );
196                }
197
198                bail!("Could not find matching content for hunk in file. The original content expected by this patch does not match any location in the file.\n\nOriginal content being searched for:\n{}\n\nTip: Check that the file content matches what the patch expects.",
199                    hunk.patch()
200                );
201            }
202            1 => Ok(matches[0]),
203            _ => {
204                bail!("Found {} possible locations for hunk matching: \n{}.\n\nTip: Use more lines of context to make the location unique",
205                    matches.len(),
206                    hunk.patch()
207                );
208            }
209        }
210    }
211
212    /// Check if two lines match, tolerating whitespace differences
213    fn lines_match_tolerant(&self, file_line: &str, expected_line: &str) -> bool {
214        if file_line == expected_line {
215            return true;
216        }
217
218        if file_line.trim().is_empty() && expected_line.trim().is_empty() {
219            return true;
220        }
221
222        if file_line.trim_end() == expected_line.trim_end() {
223            return true;
224        }
225
226        if file_line.starts_with(' ') && &file_line[1..] == expected_line {
227            return true;
228        }
229
230        if expected_line.starts_with(' ') && &expected_line[1..] == file_line {
231            return true;
232        }
233
234        false
235    }
236
237    /// Check if expected hunk lines match file content at given position.
238    /// Uses tolerant matching to accommodate whitespace variations from models.
239    fn hunk_matches_at(
240        &self,
241        file_lines: &[String],
242        start_idx: usize,
243        expected_lines: &[String],
244    ) -> bool {
245        expected_lines.iter().enumerate().all(|(i, expected_line)| {
246            file_lines
247                .get(start_idx + i)
248                .map(|file_line| self.lines_match_tolerant(file_line, expected_line))
249                .unwrap_or(false)
250        })
251    }
252
253    /// Apply a single hunk to the file lines
254    fn apply_hunk(&self, file_lines: &mut Vec<String>, hunk: &CodexHunk) -> Result<usize> {
255        let position = self.find_hunk_position(file_lines, hunk)?;
256        let mut file_pos = position;
257        let mut hunk_line_idx = 0;
258
259        while hunk_line_idx < hunk.lines.len() {
260            let line = &hunk.lines[hunk_line_idx];
261            match line {
262                CodexHunkLine::Context(content) => {
263                    let file_line = file_lines.get(file_pos).ok_or_else(|| {
264                        anyhow::anyhow!("Context line {} does not exist", file_pos + 1)
265                    })?;
266                    if !self.lines_match_tolerant(file_line, content) {
267                        bail!(
268                            "Context mismatch at line {}: expected '{}' but found '{}'",
269                            file_pos + 1,
270                            content,
271                            file_line
272                        );
273                    }
274                    file_pos += 1;
275                }
276                CodexHunkLine::Removal(content) => {
277                    let file_line = file_lines.get(file_pos).ok_or_else(|| {
278                        anyhow::anyhow!("Cannot remove line {} - does not exist", file_pos + 1)
279                    })?;
280                    if !self.lines_match_tolerant(file_line, content) {
281                        bail!(
282                            "Removal mismatch at line {}: expected '{}' but found '{}'",
283                            file_pos + 1,
284                            content,
285                            file_line
286                        );
287                    }
288                    file_lines.remove(file_pos);
289                }
290                CodexHunkLine::Addition(content) => {
291                    file_lines.insert(file_pos, content.clone());
292                    file_pos += 1;
293                }
294            }
295            hunk_line_idx += 1;
296        }
297
298        Ok(position)
299    }
300
301    /// Apply multiple hunks individually, collecting success/failure info.
302    /// Returns success if ANY hunk was applied successfully.
303    /// Logs warnings about failed hunks with full hunk content.
304    fn apply_hunks(
305        &self,
306        content: &str,
307        hunk_strings: &[String],
308    ) -> Result<(String, Option<String>)> {
309        let mut file_lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
310        let mut successes = Vec::new();
311        let mut failures: Vec<(usize, String, String)> = Vec::new();
312
313        // Phase 1: Parse hunks individually, collect parse failures
314        let mut parsed_hunks = Vec::new();
315        for (idx, hunk_str) in hunk_strings.iter().enumerate() {
316            match self.parse_single_hunk(hunk_str) {
317                Ok(hunk) => parsed_hunks.push((idx, hunk, hunk_str.clone())),
318                Err(e) => failures.push((idx, format!("{}", e), hunk_str.clone())),
319            }
320        }
321
322        // Phase 2: Find positions for hunks individually, collect position failures
323        let mut positioned_hunks = Vec::new();
324        for (idx, hunk, hunk_str) in parsed_hunks {
325            match self.find_hunk_position(&file_lines, &hunk) {
326                Ok(pos) => positioned_hunks.push((idx, pos, hunk, hunk_str)),
327                Err(e) => failures.push((idx, format!("{}", e), hunk_str)),
328            }
329        }
330
331        // Sort by position descending (bottom to top) to avoid line number shifts
332        positioned_hunks.sort_by_key(|(_, pos, _, _)| std::cmp::Reverse(*pos));
333
334        // Phase 3: Apply each hunk individually, collect application failures
335        for (idx, _pos, hunk, hunk_str) in positioned_hunks {
336            match self.apply_hunk(&mut file_lines, &hunk) {
337                Ok(_) => successes.push(idx),
338                Err(e) => failures.push((idx, format!("{}", e), hunk_str)),
339            }
340        }
341
342        // If all hunks failed, return error with details about all failures
343        if successes.is_empty() {
344            let mut error_msg = format!("All {} hunk(s) failed:\n\n", hunk_strings.len());
345            for (idx, error, content) in &failures {
346                error_msg.push_str(&format!("Hunk {} failed:\n", idx));
347                error_msg.push_str(&format!("Error: {}\n", error));
348                error_msg.push_str(&format!("Hunk content:\n{}\n\n", content));
349            }
350            return Err(anyhow::anyhow!(error_msg));
351        }
352
353        // If some failed but others succeeded, log warnings and return success with modified content
354        if !failures.is_empty() {
355            let mut warning_msg = format!(
356                "Applied {}/{} hunks. {} failed and were skipped:\n\n",
357                successes.len(),
358                hunk_strings.len(),
359                failures.len()
360            );
361            for (idx, error, content) in &failures {
362                warning_msg.push_str(&format!("Hunk {} failed:\n", idx));
363                warning_msg.push_str(&format!("Error: {}\n", error));
364                warning_msg.push_str(&format!("Hunk content:\n{}\n\n", content));
365            }
366            return Ok((file_lines.join("\n"), Some(warning_msg)));
367        }
368
369        Ok((file_lines.join("\n"), None))
370    }
371}
372
373struct ApplyCodexPatchHandle {
374    modification: FileModification,
375    tool_use_id: String,
376    file_manager: FileAccessManager,
377}
378
379#[async_trait::async_trait(?Send)]
380impl ToolCallHandle for ApplyCodexPatchHandle {
381    fn tool_request(&self) -> ToolRequestEvent {
382        ToolRequestEvent {
383            tool_call_id: self.tool_use_id.clone(),
384            tool_name: "modify_file".to_string(),
385            tool_type: ToolRequestType::ModifyFile {
386                file_path: self.modification.path.to_string_lossy().to_string(),
387                before: self
388                    .modification
389                    .original_content
390                    .clone()
391                    .unwrap_or_default(),
392                after: self.modification.new_content.clone().unwrap_or_default(),
393            },
394        }
395    }
396
397    async fn execute(self: Box<Self>) -> ToolOutput {
398        let manager = FileModificationManager::new(self.file_manager.clone());
399        match manager.apply_modification(self.modification).await {
400            Ok(stats) => ToolOutput::Result {
401                content: json!({
402                    "success": true,
403                    "lines_added": stats.lines_added,
404                    "lines_removed": stats.lines_removed
405                })
406                .to_string(),
407                is_error: false,
408                continuation: ContinuationPreference::Continue,
409                ui_result: ToolExecutionResult::ModifyFile {
410                    lines_added: stats.lines_added,
411                    lines_removed: stats.lines_removed,
412                },
413            },
414            Err(e) => ToolOutput::Result {
415                content: format!("Failed to apply codex patch: {e:?}"),
416                is_error: true,
417                continuation: ContinuationPreference::Continue,
418                ui_result: ToolExecutionResult::Error {
419                    short_message: "Codex patch failed".to_string(),
420                    detailed_message: format!("{e:?}"),
421                },
422            },
423        }
424    }
425}
426
427#[async_trait::async_trait(?Send)]
428impl ToolExecutor for ApplyCodexPatchTool {
429    fn name(&self) -> String {
430        "modify_file".to_string()
431    }
432
433    fn description(&self) -> String {
434        "Modify a file by applying multiple hunks in a single call (no line numbers required). Each hunk independently specifies a location and changes to apply.".to_string()
435    }
436
437    fn input_schema(&self) -> Value {
438        json!({
439            "type": "object",
440            "properties": {
441                "file_path": {
442                    "type": "string",
443                    "description": "Absolute path to the file to patch"
444                },
445                "hunks": {
446                    "type": "string",
447                    "description": r#"One or more diffs to apply to the file. Multiple independent changes can be applied in a single call by separating hunks with @@ markers.
448
449Each hunk shows which lines to keep (context), remove, or add:
450- Lines starting with ' ' (space) = context - existing lines that help locate where to make changes
451- Lines starting with '-' = remove this line
452- Lines starting with '+' = add this line
453
454The tool finds the right location by matching the context lines, then applies the additions and removals.
455
456Example - to change 'line 3' to 'line 3 modified':
457 line 2
458-line 3
459+line 3 modified
460 line 4
461
462Example - multiple changes in one call:
463 line 2
464-line 3
465+line 3 modified
466 line 4
467@@
468 line 10
469-line 11
470+line 11 updated
471 line 12
472
473Use enough context lines to uniquely identify each location."#
474                }
475            },
476            "required": ["file_path", "hunks"]
477        })
478    }
479
480    fn category(&self) -> ToolCategory {
481        ToolCategory::Execution
482    }
483
484    async fn process(&self, request: &ToolRequest) -> Result<Box<dyn ToolCallHandle>> {
485        let file_path = request
486            .arguments
487            .get("file_path")
488            .and_then(|v| v.as_str())
489            .ok_or_else(|| anyhow::anyhow!("Missing required parameter: file_path"))?;
490
491        let hunks_string = request
492            .arguments
493            .get("hunks")
494            .and_then(|v| v.as_str())
495            .ok_or_else(|| {
496                anyhow::anyhow!("Missing required parameter: hunks (must be a string)")
497            })?;
498
499        if hunks_string.trim().is_empty() {
500            bail!("hunks string must not be empty");
501        }
502
503        let hunk_strings = self.split_hunks_on_markers(&[hunks_string.to_string()]);
504        let original_content: String = self.file_manager.read_file(file_path).await?;
505        let (patched_content, warning) = self.apply_hunks(&original_content, &hunk_strings)?;
506
507        let modification = FileModification {
508            path: PathBuf::from(file_path),
509            operation: FileOperation::Update,
510            original_content: Some(original_content),
511            new_content: Some(patched_content),
512            warning,
513        };
514
515        Ok(Box::new(ApplyCodexPatchHandle {
516            modification,
517            tool_use_id: request.tool_use_id.clone(),
518            file_manager: self.file_manager.clone(),
519        }))
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use std::fs;
526
527    use super::*;
528    use tempfile::TempDir;
529
530    #[tokio::test]
531    async fn test_apply_codex_patch_simple() {
532        let temp_dir = TempDir::new().unwrap();
533        let root = temp_dir.path().join("test");
534        fs::create_dir(&root).unwrap();
535        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
536
537        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
538        let original_content = "line 1\nline 2\nline 3\nline 4\nline 5";
539        file_manager
540            .write_file("/test/test.txt", original_content)
541            .await
542            .unwrap();
543
544        let hunks = r#" line 2
545-line 3
546+line 3 modified
547 line 4"#;
548
549        let request = ToolRequest::new(
550            json!({
551                "file_path": "/test/test.txt",
552                "hunks": hunks
553            }),
554            "test_id".to_string(),
555        );
556        let handle = tool.process(&request).await.unwrap();
557        let request_event = handle.tool_request();
558
559        assert_eq!(request_event.tool_name, "modify_file");
560        if let ToolRequestType::ModifyFile {
561            file_path,
562            before,
563            after,
564        } = request_event.tool_type
565        {
566            assert_eq!(file_path, "/test/test.txt");
567            assert_eq!(before, original_content);
568            let expected_new = "line 1\nline 2\nline 3 modified\nline 4\nline 5";
569            assert_eq!(after, expected_new);
570        } else {
571            panic!("Expected ModifyFile request type");
572        }
573    }
574
575    #[tokio::test]
576    async fn test_apply_codex_patch_unprefixed_context() {
577        let temp_dir = TempDir::new().unwrap();
578        let root = temp_dir.path().join("test");
579        fs::create_dir(&root).unwrap();
580        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
581
582        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
583        let original_content = "line 1\nline 2\nline 3\nline 4\nline 5";
584        file_manager
585            .write_file("/test/test.txt", original_content)
586            .await
587            .unwrap();
588
589        let hunks = r#"line 2
590-line 3
591+line 3 modified
592line 4"#;
593
594        let request = ToolRequest::new(
595            json!({
596                "file_path": "/test/test.txt",
597                "hunks": hunks
598            }),
599            "test_id".to_string(),
600        );
601        let handle = tool.process(&request).await.unwrap();
602        let request_event = handle.tool_request();
603
604        if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
605            let expected_new = "line 1\nline 2\nline 3 modified\nline 4\nline 5";
606            assert_eq!(after, expected_new);
607        } else {
608            panic!("Expected ModifyFile request type");
609        }
610    }
611
612    #[tokio::test]
613    async fn test_apply_codex_patch_whitespace_tolerant() {
614        let temp_dir = TempDir::new().unwrap();
615        let root = temp_dir.path().join("test");
616        fs::create_dir(&root).unwrap();
617        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
618
619        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
620        let original_content = "line 1\nline 2\n line 3\nline 4";
621        file_manager
622            .write_file("/test/test.txt", original_content)
623            .await
624            .unwrap();
625
626        let hunks = r#" line 2
627 line 3
628-line 4
629+line 5"#;
630
631        let request = ToolRequest::new(
632            json!({
633                "file_path": "/test/test.txt",
634                "hunks": hunks
635            }),
636            "test_id".to_string(),
637        );
638        let handle = tool.process(&request).await.unwrap();
639        let request_event = handle.tool_request();
640
641        if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
642            let expected_new = "line 1\nline 2\n line 3\nline 5";
643            assert_eq!(after, expected_new);
644        } else {
645            panic!("Expected ModifyFile request type");
646        }
647    }
648
649    #[tokio::test]
650    async fn test_apply_codex_patch_add_only() {
651        let temp_dir = TempDir::new().unwrap();
652        let root = temp_dir.path().join("test");
653        fs::create_dir(&root).unwrap();
654        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
655
656        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
657        let original_content = "line 1\nline 2\nline 3";
658        file_manager
659            .write_file("/test/test.txt", original_content)
660            .await
661            .unwrap();
662
663        let hunks = r#" line 1
664+ added line
665 line 2"#;
666
667        let request = ToolRequest::new(
668            json!({
669                "file_path": "/test/test.txt",
670                "hunks": hunks
671            }),
672            "test_id".to_string(),
673        );
674        let handle = tool.process(&request).await.unwrap();
675        let request_event = handle.tool_request();
676
677        if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
678            let expected_new = "line 1\n added line\nline 2\nline 3";
679            assert_eq!(after, expected_new);
680        } else {
681            panic!("Expected ModifyFile request type");
682        }
683    }
684
685    #[tokio::test]
686    async fn test_apply_codex_patch_invalid_format() {
687        let temp_dir = TempDir::new().unwrap();
688        let root = temp_dir.path().join("test");
689        fs::create_dir(&root).unwrap();
690        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
691
692        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
693        let original_content = "line 1\nline 2";
694        file_manager
695            .write_file("/test/test.txt", original_content)
696            .await
697            .unwrap();
698
699        let hunks = " line 1\n line 2";
700
701        let request = ToolRequest::new(
702            json!({
703                "file_path": "/test/test.txt",
704                "hunks": hunks
705            }),
706            "test_id".to_string(),
707        );
708        let result = tool.process(&request).await;
709
710        assert!(result.is_err());
711        let err = result.err().unwrap();
712        assert!(err
713            .to_string()
714            .contains("must contain at least one addition"));
715    }
716
717    #[tokio::test]
718    async fn test_apply_codex_patch_multiple_hunks() {
719        let temp_dir = TempDir::new().unwrap();
720        let root = temp_dir.path().join("test");
721        fs::create_dir(&root).unwrap();
722        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
723
724        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
725        let original_content = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7";
726        file_manager
727            .write_file("/test/test.txt", original_content)
728            .await
729            .unwrap();
730
731        let hunks = r#" line 2
732-line 3
733+line 3 modified
734 line 4
735@@
736 line 6
737-line 7
738+line 7 updated"#;
739
740        let request = ToolRequest::new(
741            json!({
742                "file_path": "/test/test.txt",
743                "hunks": hunks
744            }),
745            "test_id".to_string(),
746        );
747        let handle = tool.process(&request).await.unwrap();
748        let request_event = handle.tool_request();
749
750        if let ToolRequestType::ModifyFile { before, after, .. } = request_event.tool_type {
751            let expected_new =
752                "line 1\nline 2\nline 3 modified\nline 4\nline 5\nline 6\nline 7 updated";
753            assert_eq!(after, expected_new);
754            assert_eq!(before, original_content);
755        } else {
756            panic!("Expected ModifyFile request type");
757        }
758    }
759
760    #[tokio::test]
761    async fn test_apply_codex_patch_interleaved_changes() {
762        let temp_dir = TempDir::new().unwrap();
763        let root = temp_dir.path().join("test");
764        fs::create_dir(&root).unwrap();
765        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
766
767        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
768        let original_content = "some context\nsome line to remove\nsome other context\nanother to remove\nfinal context";
769        file_manager
770            .write_file("/test/test.txt", original_content)
771            .await
772            .unwrap();
773
774        let hunks = r#" some context
775+ insert A
776-some line to remove
777 some other context
778+ insert B
779-another to remove
780 final context"#;
781
782        let request = ToolRequest::new(
783            json!({
784                "file_path": "/test/test.txt",
785                "hunks": hunks
786            }),
787            "test_id".to_string(),
788        );
789        let handle = tool.process(&request).await.unwrap();
790        let request_event = handle.tool_request();
791
792        if let ToolRequestType::ModifyFile { before, after, .. } = request_event.tool_type {
793            let expected_new =
794                "some context\n insert A\nsome other context\n insert B\nfinal context";
795            assert_eq!(after, expected_new);
796            assert_eq!(before, original_content);
797        } else {
798            panic!("Expected ModifyFile request type");
799        }
800    }
801
802    #[tokio::test]
803    async fn test_strip_leading_trailing_markers() {
804        let temp_dir = TempDir::new().unwrap();
805        let root = temp_dir.path().join("test");
806        fs::create_dir(&root).unwrap();
807        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
808
809        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
810        let original_content = "line 1\nline 2\nline 3";
811        file_manager
812            .write_file("/test/test.txt", original_content)
813            .await
814            .unwrap();
815
816        let hunks = r#"@@
817 line 1
818-line 2
819+line 2 modified
820 line 3
821@@"#;
822
823        let request = ToolRequest::new(
824            json!({
825                "file_path": "/test/test.txt",
826                "hunks": hunks
827            }),
828            "test_id".to_string(),
829        );
830        let handle = tool.process(&request).await.unwrap();
831        let request_event = handle.tool_request();
832
833        if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
834            let expected_new = "line 1\nline 2 modified\nline 3";
835            assert_eq!(after, expected_new);
836        } else {
837            panic!("Expected ModifyFile request type");
838        }
839    }
840
841    #[tokio::test]
842    async fn test_apply_codex_patch_partial_failure() {
843        let temp_dir = TempDir::new().unwrap();
844        let root = temp_dir.path().join("test");
845        fs::create_dir(&root).unwrap();
846        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
847
848        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
849        let original_content = "line 1\nline 2\nline 3\nline 4\nline 5";
850        file_manager
851            .write_file("/test/test.txt", original_content)
852            .await
853            .unwrap();
854
855        let hunks = r#" line 1
856-line 2
857+line 2 modified
858 line 3
859@@
860 nonexistent
861-line should fail
862+replacement"#;
863
864        let request = ToolRequest::new(
865            json!({
866                "file_path": "/test/test.txt",
867                "hunks": hunks
868            }),
869            "test_id".to_string(),
870        );
871        let handle = tool.process(&request).await.unwrap();
872        let request_event = handle.tool_request();
873
874        if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
875            let expected_new = "line 1\nline 2 modified\nline 3\nline 4\nline 5";
876            assert_eq!(after, expected_new);
877        } else {
878            panic!("Expected ModifyFile request type");
879        }
880    }
881
882    #[tokio::test]
883    async fn test_merge_conflict_resolution() {
884        let temp_dir = TempDir::new().unwrap();
885        let root = temp_dir.path().join("test");
886        fs::create_dir(&root).unwrap();
887        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
888
889        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
890        let original_content = r#"fn sum_numbers(numbers: Vec<i32>) -> i32 {
891    let mut total = 0;
892    for num in numbers {
893        total += num;
894        <<<<<<< HEAD
895        // Old debug output
896        println!("Adding {} to total", num);
897        =======
898        // New debug output with emoji! 🎯
899        println!("🔢 Adding {} to total 📊", num);
900        >>>>>>> branch-feature-emoji-logs
901    }
902    return total;
903}"#;
904        file_manager
905            .write_file("/test/conflict.rs", original_content)
906            .await
907            .unwrap();
908
909        let hunks = r#"    for num in numbers {
910        total += num;
911-        <<<<<<< HEAD
912-        // Old debug output
913-        println!("Adding {} to total", num);
914-        =======
915-        // New debug output with emoji! 🎯
916         println!("🔢 Adding {} to total 📊", num);
917-        >>>>>>> branch-feature-emoji-logs
918    }
919    return total;"#;
920
921        let request = ToolRequest::new(
922            json!({
923                "file_path": "/test/conflict.rs",
924                "hunks": hunks
925            }),
926            "test_id".to_string(),
927        );
928        let result = tool.process(&request).await;
929
930        match result {
931            Ok(handle) => {
932                let request_event = handle.tool_request();
933                if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
934                    let expected_new = r#"fn sum_numbers(numbers: Vec<i32>) -> i32 {
935    let mut total = 0;
936    for num in numbers {
937        total += num;
938        println!("🔢 Adding {} to total 📊", num);
939    }
940    return total;
941}"#;
942                    assert_eq!(after, expected_new);
943                } else {
944                    panic!("Expected ModifyFile request type");
945                }
946            }
947            Err(e) => {
948                panic!("Merge conflict resolution failed: {}", e);
949            }
950        }
951    }
952
953    #[tokio::test]
954    async fn test_whitespace_mismatch_in_context() {
955        let temp_dir = TempDir::new().unwrap();
956        let root = temp_dir.path().join("test");
957        fs::create_dir(&root).unwrap();
958        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
959
960        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
961        let original_content = "    line with 4 spaces\n        line with 8 spaces\n    back to 4";
962        file_manager
963            .write_file("/test/whitespace.txt", original_content)
964            .await
965            .unwrap();
966
967        let hunks = r#"    line with 4 spaces
968-        line with 8 spaces
969+         line with 9 spaces
970    back to 4"#;
971
972        let request = ToolRequest::new(
973            json!({
974                "file_path": "/test/whitespace.txt",
975                "hunks": hunks
976            }),
977            "test_id".to_string(),
978        );
979        let handle = tool.process(&request).await.unwrap();
980        let request_event = handle.tool_request();
981
982        if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
983            let expected_new = "    line with 4 spaces\n         line with 9 spaces\n    back to 4";
984            assert_eq!(after, expected_new);
985        } else {
986            panic!("Expected ModifyFile request type");
987        }
988    }
989
990    #[tokio::test]
991    async fn test_hunk_with_extra_leading_space_in_context() {
992        let temp_dir = TempDir::new().unwrap();
993        let root = temp_dir.path().join("test");
994        fs::create_dir(&root).unwrap();
995        let tool = ApplyCodexPatchTool::new(vec![root.clone()]).unwrap();
996
997        let file_manager = FileAccessManager::new(vec![root.clone()]).unwrap();
998        let original_content = "line 1\n        line 2 with 8 spaces\nline 3";
999        file_manager
1000            .write_file("/test/test.txt", original_content)
1001            .await
1002            .unwrap();
1003
1004        let hunks = r#" line 1
1005         line 2 with 8 spaces
1006-line 3
1007+line 3 modified"#;
1008
1009        let request = ToolRequest::new(
1010            json!({
1011                "file_path": "/test/test.txt",
1012                "hunks": hunks
1013            }),
1014            "test_id".to_string(),
1015        );
1016        let result = tool.process(&request).await;
1017
1018        match result {
1019            Ok(handle) => {
1020                let request_event = handle.tool_request();
1021                if let ToolRequestType::ModifyFile { after, .. } = request_event.tool_type {
1022                    let expected_new = "line 1\n        line 2 with 8 spaces\nline 3 modified";
1023                    assert_eq!(after, expected_new);
1024                } else {
1025                    panic!("Expected ModifyFile request type");
1026                }
1027            }
1028            Err(e) => {
1029                println!("Error (this reveals the bug): {}", e);
1030                panic!("Hunk matching failed due to whitespace handling bug: {}", e);
1031            }
1032        }
1033    }
1034
1035    #[test]
1036    fn test_lines_match_tolerant_asymmetry() {
1037        let tool = ApplyCodexPatchTool::new(vec![]).unwrap();
1038
1039        assert!(tool.lines_match_tolerant("line content", "line content"));
1040
1041        assert!(tool.lines_match_tolerant(" line content", "line content"));
1042
1043        let result = tool.lines_match_tolerant("line content", " line content");
1044        assert!(
1045            result,
1046            "Bug: lines_match_tolerant is asymmetric. It tolerates file having extra space but not expected having extra space."
1047        );
1048    }
1049}