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#[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 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 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 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 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 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 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 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 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 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 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 positioned_hunks.sort_by_key(|(_, pos, _, _)| std::cmp::Reverse(*pos));
333
334 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 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 !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}