llm_git/
diff.rs

1/// Diff parsing and smart truncation logic
2use crate::config::CommitConfig;
3
4#[derive(Debug, Clone)]
5pub struct FileDiff {
6   pub filename:  String,
7   pub header:    String, // The diff header (@@, index, etc)
8   pub content:   String, // The actual diff content
9   pub additions: usize,
10   pub deletions: usize,
11   pub is_binary: bool,
12}
13
14impl FileDiff {
15   pub const fn size(&self) -> usize {
16      self.header.len() + self.content.len()
17   }
18
19   pub fn priority(&self, config: &CommitConfig) -> i32 {
20      // Higher number = higher priority
21      if self.is_binary {
22         return -100; // Lowest priority
23      }
24
25      // Critical dependency manifests get medium-high priority despite extension
26      let filename_lower = self.filename.to_lowercase();
27      if filename_lower.ends_with("cargo.toml")
28         || filename_lower.ends_with("package.json")
29         || filename_lower.ends_with("go.mod")
30         || filename_lower.ends_with("requirements.txt")
31         || filename_lower.ends_with("pyproject.toml")
32      {
33         return 70; // Medium-high priority for dependency manifests (below source/SQL, above default)
34      }
35
36      // Check if it's a test file (lower priority)
37      if self.filename.contains("/test")
38         || self.filename.contains("test_")
39         || self.filename.contains("_test.")
40         || self.filename.contains(".test.")
41      {
42         return 10;
43      }
44
45      // Check file extension
46      let ext = self.filename.rsplit('.').next().unwrap_or("");
47      if config
48         .low_priority_extensions
49         .iter()
50         .any(|e| e.trim_start_matches('.') == ext)
51      {
52         return 20;
53      }
54
55      // Source code files get highest priority
56      match ext {
57         "rs" | "go" | "py" | "js" | "ts" | "java" | "c" | "cpp" | "h" | "hpp" => 100,
58         "sql" | "sh" | "bash" => 80,
59         _ => 50,
60      }
61   }
62
63   pub fn truncate(&mut self, max_size: usize) {
64      if self.size() <= max_size {
65         return;
66      }
67
68      // Keep the header, truncate content
69      let available = max_size.saturating_sub(self.header.len() + 50); // Reserve space for truncation message
70
71      if available < 50 {
72         // Too small, just keep header
73         self.content = "... (truncated)".to_string();
74      } else {
75         // Try to keep beginning and end of the diff
76         let lines: Vec<&str> = self.content.lines().collect();
77         if lines.len() > 30 {
78            // Keep first 15 and last 10 lines to show both what was added/removed
79            let keep_start = 15;
80            let keep_end = 10;
81            let omitted = lines.len() - keep_start - keep_end;
82            // Pre-allocate capacity
83            let est_size = keep_start * 60 + keep_end * 60 + 50;
84            let mut truncated = String::with_capacity(est_size);
85            for (i, line) in lines[..keep_start].iter().enumerate() {
86               if i > 0 {
87                  truncated.push('\n');
88               }
89               truncated.push_str(line);
90            }
91            use std::fmt::Write;
92            write!(&mut truncated, "\n... (truncated {omitted} lines) ...\n").unwrap();
93            for (i, line) in lines[lines.len() - keep_end..].iter().enumerate() {
94               if i > 0 {
95                  truncated.push('\n');
96               }
97               truncated.push_str(line);
98            }
99            self.content = truncated;
100         } else {
101            // Just truncate the content
102            self.content.truncate(available);
103            self.content.push_str("\n... (truncated)");
104         }
105      }
106   }
107}
108
109/// Parse a git diff into individual file diffs
110pub fn parse_diff(diff: &str) -> Vec<FileDiff> {
111   let mut file_diffs = Vec::new();
112   let mut current_file: Option<FileDiff> = None;
113   let mut in_diff_header = false;
114
115   for line in diff.lines() {
116      if line.starts_with("diff --git") {
117         // Save previous file if exists
118         if let Some(file) = current_file.take() {
119            file_diffs.push(file);
120         }
121
122         // Extract filename from diff line - avoid allocation until we know we need it
123         let filename = line
124            .split_whitespace()
125            .nth(3)
126            .map_or("unknown", |s| s.trim_start_matches("b/"))
127            .to_string();
128
129         current_file = Some(FileDiff {
130            filename,
131            header: String::from(line),
132            content: String::new(),
133            additions: 0,
134            deletions: 0,
135            is_binary: false,
136         });
137         in_diff_header = true;
138      } else if let Some(ref mut file) = current_file {
139         if line.starts_with("Binary files") {
140            file.is_binary = true;
141            file.header.reserve(line.len() + 1);
142            file.header.push('\n');
143            file.header.push_str(line);
144         } else if line.starts_with("index ")
145            || line.starts_with("new file")
146            || line.starts_with("deleted file")
147            || line.starts_with("rename ")
148            || line.starts_with("similarity index")
149            || line.starts_with("+++")
150            || line.starts_with("---")
151         {
152            // Part of the header
153            file.header.reserve(line.len() + 1);
154            file.header.push('\n');
155            file.header.push_str(line);
156         } else if line.starts_with("@@") {
157            // Hunk header - marks end of file header, start of content
158            in_diff_header = false;
159            file.header.reserve(line.len() + 1);
160            file.header.push('\n');
161            file.header.push_str(line);
162         } else if !in_diff_header {
163            // Actual diff content
164            if !file.content.is_empty() {
165               file.content.push('\n');
166            }
167            file.content.push_str(line);
168
169            if line.starts_with('+') && !line.starts_with("+++") {
170               file.additions += 1;
171            } else if line.starts_with('-') && !line.starts_with("---") {
172               file.deletions += 1;
173            }
174         } else {
175            // Still in header
176            file.header.reserve(line.len() + 1);
177            file.header.push('\n');
178            file.header.push_str(line);
179         }
180      }
181   }
182
183   // Don't forget the last file
184   if let Some(file) = current_file {
185      file_diffs.push(file);
186   }
187
188   file_diffs
189}
190
191/// Smart truncation of git diff
192pub fn smart_truncate_diff(diff: &str, max_length: usize, config: &CommitConfig) -> String {
193   let mut file_diffs = parse_diff(diff);
194
195   // Filter out excluded files
196   file_diffs.retain(|f| {
197      !config
198         .excluded_files
199         .iter()
200         .any(|excluded| f.filename.ends_with(excluded))
201   });
202
203   if file_diffs.is_empty() {
204      return "No relevant files to analyze (only lock files or excluded files were changed)"
205         .to_string();
206   }
207
208   // Sort by priority (highest first)
209   file_diffs.sort_by_key(|f| -f.priority(config));
210
211   // Calculate total size
212   let total_size: usize = file_diffs.iter().map(|f| f.size()).sum();
213
214   if total_size <= max_length {
215      // Everything fits, reconstruct the diff
216      return reconstruct_diff(&file_diffs);
217   }
218
219   // Strategy: Prioritize showing ALL file headers, even if we must truncate
220   // content aggressively This ensures the LLM sees the full scope of changes
221   let mut included_files = Vec::new();
222   let mut current_size = 0;
223
224   // First pass: include all files with minimal content to show the scope
225   let header_only_size: usize = file_diffs.iter().map(|f| f.header.len() + 20).sum();
226   let total_files = file_diffs.len();
227
228   if header_only_size <= max_length {
229      // We can fit all headers, now distribute remaining space for content
230      let remaining_space = max_length - header_only_size;
231      let space_per_file = if file_diffs.is_empty() {
232         0
233      } else {
234         remaining_space / file_diffs.len()
235      };
236
237      included_files.reserve(file_diffs.len());
238      for file in file_diffs {
239         if file.is_binary {
240            // Include binary files with just header
241            included_files.push(FileDiff {
242               filename:  file.filename,
243               header:    file.header,
244               content:   String::new(),
245               additions: file.additions,
246               deletions: file.deletions,
247               is_binary: true,
248            });
249         } else {
250            let mut truncated = file;
251            let target_size = truncated.header.len() + space_per_file;
252            if truncated.size() > target_size {
253               truncated.truncate(target_size);
254            }
255            included_files.push(truncated);
256         }
257      }
258   } else {
259      // Even headers don't fit, fall back to including top priority files
260      for mut file in file_diffs {
261         if file.is_binary {
262            continue; // Skip binary files when severely constrained
263         }
264
265         let file_size = file.size();
266         if current_size + file_size <= max_length {
267            current_size += file_size;
268            included_files.push(file);
269         } else if current_size < max_length / 2 && file.priority(config) >= 50 {
270            // If we haven't used half the space and this is important, truncate and include
271            // it
272            let remaining = max_length - current_size;
273            file.truncate(remaining.saturating_sub(100)); // Leave some space
274            included_files.push(file);
275            break;
276         }
277      }
278   }
279
280   if included_files.is_empty() {
281      return "Error: Could not include any files in the diff".to_string();
282   }
283
284   let mut result = reconstruct_diff(&included_files);
285
286   // Add a note about excluded files if any
287   let excluded_count = total_files - included_files.len();
288   if excluded_count > 0 {
289      use std::fmt::Write;
290      write!(result, "\n\n... ({excluded_count} files omitted) ...").unwrap();
291   }
292
293   result
294}
295
296/// Reconstruct a diff from `FileDiff` objects
297pub fn reconstruct_diff(files: &[FileDiff]) -> String {
298   // Pre-allocate capacity based on file sizes
299   let capacity: usize = files.iter().map(|f| f.size() + 1).sum();
300   let mut result = String::with_capacity(capacity);
301
302   for (i, file) in files.iter().enumerate() {
303      if i > 0 {
304         result.push('\n');
305      }
306      result.push_str(&file.header);
307      if !file.content.is_empty() {
308         result.push('\n');
309         result.push_str(&file.content);
310      }
311   }
312
313   result
314}
315
316#[cfg(test)]
317mod tests {
318   use super::*;
319
320   fn test_config() -> CommitConfig {
321      CommitConfig::default()
322   }
323
324   #[test]
325   fn test_parse_diff_simple() {
326      let diff = r#"diff --git a/src/main.rs b/src/main.rs
327index 123..456 100644
328--- a/src/main.rs
329+++ b/src/main.rs
330@@ -1,3 +1,4 @@
331+use std::collections::HashMap;
332 fn main() {
333     println!("hello");
334 }"#;
335      let files = parse_diff(diff);
336      assert_eq!(files.len(), 1);
337      assert_eq!(files[0].filename, "src/main.rs");
338      assert_eq!(files[0].additions, 1);
339      assert_eq!(files[0].deletions, 0);
340      assert!(!files[0].is_binary);
341      assert!(files[0].header.contains("diff --git"));
342      assert!(files[0].content.contains("use std::collections::HashMap"));
343   }
344
345   #[test]
346   fn test_parse_diff_multi_file() {
347      let diff = r"diff --git a/src/lib.rs b/src/lib.rs
348index 111..222 100644
349--- a/src/lib.rs
350+++ b/src/lib.rs
351@@ -1,2 +1,3 @@
352+pub mod utils;
353 pub fn test() {}
354diff --git a/src/main.rs b/src/main.rs
355index 333..444 100644
356--- a/src/main.rs
357+++ b/src/main.rs
358@@ -1,1 +1,2 @@
359 fn main() {}
360+fn helper() {}";
361      let files = parse_diff(diff);
362      assert_eq!(files.len(), 2);
363      assert_eq!(files[0].filename, "src/lib.rs");
364      assert_eq!(files[1].filename, "src/main.rs");
365      assert_eq!(files[0].additions, 1);
366      assert_eq!(files[1].additions, 1);
367   }
368
369   #[test]
370   fn test_parse_diff_rename() {
371      let diff = r"diff --git a/old.rs b/new.rs
372similarity index 95%
373rename from old.rs
374rename to new.rs
375index 123..456 100644
376--- a/old.rs
377+++ b/new.rs
378@@ -1,2 +1,3 @@
379 fn test() {}
380+fn helper() {}";
381      let files = parse_diff(diff);
382      assert_eq!(files.len(), 1);
383      assert_eq!(files[0].filename, "new.rs");
384      assert!(files[0].header.contains("rename from"));
385      assert!(files[0].header.contains("rename to"));
386      assert_eq!(files[0].additions, 1);
387   }
388
389   #[test]
390   fn test_parse_diff_binary() {
391      let diff = r"diff --git a/image.png b/image.png
392index 123..456 100644
393Binary files a/image.png and b/image.png differ";
394      let files = parse_diff(diff);
395      assert_eq!(files.len(), 1);
396      assert_eq!(files[0].filename, "image.png");
397      assert!(files[0].is_binary);
398      assert!(files[0].header.contains("Binary files"));
399   }
400
401   #[test]
402   fn test_parse_diff_empty() {
403      let diff = "";
404      let files = parse_diff(diff);
405      assert_eq!(files.len(), 0);
406   }
407
408   #[test]
409   fn test_parse_diff_malformed_missing_hunks() {
410      let diff = r"diff --git a/src/main.rs b/src/main.rs
411index 123..456 100644
412--- a/src/main.rs
413+++ b/src/main.rs";
414      let files = parse_diff(diff);
415      assert_eq!(files.len(), 1);
416      assert_eq!(files[0].filename, "src/main.rs");
417      assert!(files[0].content.is_empty());
418   }
419
420   #[test]
421   fn test_parse_diff_new_file() {
422      let diff = r"diff --git a/new.rs b/new.rs
423new file mode 100644
424index 000..123 100644
425--- /dev/null
426+++ b/new.rs
427@@ -0,0 +1,2 @@
428+fn test() {}
429+fn main() {}";
430      let files = parse_diff(diff);
431      assert_eq!(files.len(), 1);
432      assert_eq!(files[0].filename, "new.rs");
433      assert!(files[0].header.contains("new file mode"));
434      assert_eq!(files[0].additions, 2);
435   }
436
437   #[test]
438   fn test_parse_diff_deleted_file() {
439      let diff = r"diff --git a/old.rs b/old.rs
440deleted file mode 100644
441index 123..000 100644
442--- a/old.rs
443+++ /dev/null
444@@ -1,2 +0,0 @@
445-fn test() {}
446-fn main() {}";
447      let files = parse_diff(diff);
448      assert_eq!(files.len(), 1);
449      assert_eq!(files[0].filename, "old.rs");
450      assert!(files[0].header.contains("deleted file mode"));
451      assert_eq!(files[0].deletions, 2);
452   }
453
454   #[test]
455   fn test_file_diff_size() {
456      let file = FileDiff {
457         filename:  "test.rs".to_string(),
458         header:    "header".to_string(),
459         content:   "content".to_string(),
460         additions: 0,
461         deletions: 0,
462         is_binary: false,
463      };
464      assert_eq!(file.size(), 6 + 7); // "header" + "content"
465   }
466
467   #[test]
468   fn test_file_diff_priority_source_files() {
469      let config = test_config();
470      let rs_file = FileDiff {
471         filename:  "src/main.rs".to_string(),
472         header:    String::new(),
473         content:   String::new(),
474         additions: 0,
475         deletions: 0,
476         is_binary: false,
477      };
478      assert_eq!(rs_file.priority(&config), 100);
479
480      let py_file = FileDiff {
481         filename:  "script.py".to_string(),
482         header:    String::new(),
483         content:   String::new(),
484         additions: 0,
485         deletions: 0,
486         is_binary: false,
487      };
488      assert_eq!(py_file.priority(&config), 100);
489
490      let js_file = FileDiff {
491         filename:  "app.js".to_string(),
492         header:    String::new(),
493         content:   String::new(),
494         additions: 0,
495         deletions: 0,
496         is_binary: false,
497      };
498      assert_eq!(js_file.priority(&config), 100);
499   }
500
501   #[test]
502   fn test_file_diff_priority_binary() {
503      let config = test_config();
504      let binary = FileDiff {
505         filename:  "image.png".to_string(),
506         header:    String::new(),
507         content:   String::new(),
508         additions: 0,
509         deletions: 0,
510         is_binary: true,
511      };
512      assert_eq!(binary.priority(&config), -100);
513   }
514
515   #[test]
516   fn test_file_diff_priority_test_files() {
517      let config = test_config();
518      let test_file = FileDiff {
519         filename:  "src/test_utils.rs".to_string(),
520         header:    String::new(),
521         content:   String::new(),
522         additions: 0,
523         deletions: 0,
524         is_binary: false,
525      };
526      assert_eq!(test_file.priority(&config), 10);
527
528      let test_dir = FileDiff {
529         filename:  "tests/integration_test.rs".to_string(),
530         header:    String::new(),
531         content:   String::new(),
532         additions: 0,
533         deletions: 0,
534         is_binary: false,
535      };
536      assert_eq!(test_dir.priority(&config), 10);
537   }
538
539   #[test]
540   fn test_file_diff_priority_low_priority_extensions() {
541      let config = test_config();
542      let md_file = FileDiff {
543         filename:  "README.md".to_string(),
544         header:    String::new(),
545         content:   String::new(),
546         additions: 0,
547         deletions: 0,
548         is_binary: false,
549      };
550      assert_eq!(md_file.priority(&config), 20);
551
552      let toml_file = FileDiff {
553         filename:  "config.toml".to_string(),
554         header:    String::new(),
555         content:   String::new(),
556         additions: 0,
557         deletions: 0,
558         is_binary: false,
559      };
560      assert_eq!(toml_file.priority(&config), 20);
561   }
562
563   #[test]
564   fn test_file_diff_priority_dependency_manifests() {
565      let config = test_config();
566
567      let cargo_toml = FileDiff {
568         filename:  "Cargo.toml".to_string(),
569         header:    String::new(),
570         content:   String::new(),
571         additions: 0,
572         deletions: 0,
573         is_binary: false,
574      };
575      assert_eq!(cargo_toml.priority(&config), 70);
576
577      let package_json = FileDiff {
578         filename:  "package.json".to_string(),
579         header:    String::new(),
580         content:   String::new(),
581         additions: 0,
582         deletions: 0,
583         is_binary: false,
584      };
585      assert_eq!(package_json.priority(&config), 70);
586
587      let go_mod = FileDiff {
588         filename:  "go.mod".to_string(),
589         header:    String::new(),
590         content:   String::new(),
591         additions: 0,
592         deletions: 0,
593         is_binary: false,
594      };
595      assert_eq!(go_mod.priority(&config), 70);
596   }
597
598   #[test]
599   fn test_file_diff_priority_default() {
600      let config = test_config();
601      let other = FileDiff {
602         filename:  "data.csv".to_string(),
603         header:    String::new(),
604         content:   String::new(),
605         additions: 0,
606         deletions: 0,
607         is_binary: false,
608      };
609      assert_eq!(other.priority(&config), 50);
610   }
611
612   #[test]
613   fn test_file_diff_truncate_small() {
614      let mut file = FileDiff {
615         filename:  "test.rs".to_string(),
616         header:    "header".to_string(),
617         content:   "short content".to_string(),
618         additions: 0,
619         deletions: 0,
620         is_binary: false,
621      };
622      let original_size = file.size();
623      file.truncate(1000);
624      assert_eq!(file.size(), original_size);
625      assert_eq!(file.content, "short content");
626   }
627
628   #[test]
629   fn test_file_diff_truncate_large() {
630      let lines: Vec<String> = (0..100).map(|i| format!("line {i}")).collect();
631      let content = lines.join("\n");
632      let mut file = FileDiff {
633         filename: "test.rs".to_string(),
634         header: "header".to_string(),
635         content,
636         additions: 0,
637         deletions: 0,
638         is_binary: false,
639      };
640      file.truncate(500);
641      assert!(file.content.contains("... (truncated"));
642      assert!(file.content.contains("line 0")); // First line preserved
643      assert!(file.content.contains("line 99")); // Last line preserved
644   }
645
646   #[test]
647   fn test_file_diff_truncate_preserves_context() {
648      let lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
649      let content = lines.join("\n");
650      let original_lines = content.lines().count();
651      let mut file = FileDiff {
652         filename: "test.rs".to_string(),
653         header: "header".to_string(),
654         content,
655         additions: 0,
656         deletions: 0,
657         is_binary: false,
658      };
659      // Use a size that will definitely trigger truncation
660      file.truncate(300);
661      // Should keep first 15 and last 10 lines
662      assert!(file.content.contains("line 0"));
663      assert!(file.content.contains("line 14"));
664      assert!(file.content.contains("line 40"));
665      assert!(file.content.contains("line 49"));
666      // Check that truncation occurred and message is present
667      let truncated_lines = file.content.lines().count();
668      assert!(truncated_lines < original_lines, "Content should be truncated");
669      assert!(file.content.contains("truncated"), "Should have truncation message");
670   }
671
672   #[test]
673   fn test_file_diff_truncate_very_small_space() {
674      let mut file = FileDiff {
675         filename:  "test.rs".to_string(),
676         header:    "long header content here".to_string(),
677         content:   "lots of content that needs to be truncated".to_string(),
678         additions: 0,
679         deletions: 0,
680         is_binary: false,
681      };
682      file.truncate(30);
683      assert_eq!(file.content, "... (truncated)");
684   }
685
686   #[test]
687   fn test_smart_truncate_diff_under_limit() {
688      let config = test_config();
689      let diff = r"diff --git a/src/main.rs b/src/main.rs
690index 123..456 100644
691--- a/src/main.rs
692+++ b/src/main.rs
693@@ -1,2 +1,3 @@
694+use std::io;
695 fn main() {}";
696      let result = smart_truncate_diff(diff, 10000, &config);
697      assert!(result.contains("use std::io"));
698      assert!(result.contains("src/main.rs"));
699   }
700
701   #[test]
702   fn test_smart_truncate_diff_over_limit() {
703      let config = test_config();
704      let lines: Vec<String> = (0..200).map(|i| format!("+line {i}")).collect();
705      let content = lines.join("\n");
706      let diff = format!(
707         "diff --git a/src/main.rs b/src/main.rs\nindex 123..456 100644\n--- a/src/main.rs\n+++ \
708          b/src/main.rs\n@@ -1,1 +1,200 @@\n{content}"
709      );
710      let result = smart_truncate_diff(&diff, 500, &config);
711      assert!(result.len() <= 600); // Allow some overhead
712      assert!(result.contains("src/main.rs"));
713   }
714
715   #[test]
716   fn test_smart_truncate_diff_priority_allocation() {
717      let config = test_config();
718      // High priority source file and low priority markdown
719      let diff = r"diff --git a/src/lib.rs b/src/lib.rs
720index 111..222 100644
721--- a/src/lib.rs
722+++ b/src/lib.rs
723@@ -1,1 +1,50 @@
724+pub fn important_function() {}
725+pub fn another_function() {}
726+pub fn yet_another() {}
727diff --git a/README.md b/README.md
728index 333..444 100644
729--- a/README.md
730+++ b/README.md
731@@ -1,1 +1,50 @@
732+# Documentation
733+More docs here";
734      let result = smart_truncate_diff(diff, 300, &config);
735      // Should prioritize lib.rs over README.md
736      assert!(result.contains("src/lib.rs"));
737      assert!(result.contains("important_function") || result.contains("truncated"));
738   }
739
740   #[test]
741   fn test_smart_truncate_diff_binary_excluded() {
742      let config = test_config();
743      let diff = r"diff --git a/image.png b/image.png
744index 123..456 100644
745Binary files a/image.png and b/image.png differ
746diff --git a/src/main.rs b/src/main.rs
747index 789..abc 100644
748--- a/src/main.rs
749+++ b/src/main.rs
750@@ -1,1 +1,2 @@
751 fn main() {}
752+fn helper() {}";
753      let result = smart_truncate_diff(diff, 10000, &config);
754      assert!(result.contains("src/main.rs"));
755      assert!(result.contains("image.png"));
756      assert!(result.contains("Binary files"));
757   }
758
759   #[test]
760   fn test_smart_truncate_diff_excluded_files() {
761      let config = test_config();
762      let diff = r"diff --git a/Cargo.lock b/Cargo.lock
763index 123..456 100644
764--- a/Cargo.lock
765+++ b/Cargo.lock
766@@ -1,1 +1,100 @@
767+lots of lock file content
768diff --git a/src/main.rs b/src/main.rs
769index 789..abc 100644
770--- a/src/main.rs
771+++ b/src/main.rs
772@@ -1,1 +1,2 @@
773 fn main() {}
774+fn helper() {}";
775      let result = smart_truncate_diff(diff, 10000, &config);
776      assert!(!result.contains("Cargo.lock"));
777      assert!(result.contains("src/main.rs"));
778   }
779
780   #[test]
781   fn test_smart_truncate_diff_all_files_excluded() {
782      let config = test_config();
783      let diff = r"diff --git a/Cargo.lock b/Cargo.lock
784index 123..456 100644
785--- a/Cargo.lock
786+++ b/Cargo.lock
787@@ -1,1 +1,2 @@
788+dependency update";
789      let result = smart_truncate_diff(diff, 10000, &config);
790      assert!(result.contains("No relevant files"));
791   }
792
793   #[test]
794   fn test_smart_truncate_diff_header_preservation() {
795      let config = test_config();
796      let lines: Vec<String> = (0..100).map(|i| format!("+line {i}")).collect();
797      let content = lines.join("\n");
798      let diff = format!(
799         "diff --git a/src/a.rs b/src/a.rs\nindex 111..222 100644\n--- a/src/a.rs\n+++ \
800          b/src/a.rs\n@@ -1,1 +1,100 @@\n{content}\ndiff --git a/src/b.rs b/src/b.rs\nindex \
801          333..444 100644\n--- a/src/b.rs\n+++ b/src/b.rs\n@@ -1,1 +1,100 @@\n{content}"
802      );
803      let result = smart_truncate_diff(&diff, 600, &config);
804      // Both file headers should be present
805      assert!(result.contains("src/a.rs"));
806      assert!(result.contains("src/b.rs"));
807   }
808
809   #[test]
810   fn test_reconstruct_diff_single_file() {
811      let files = vec![FileDiff {
812         filename:  "test.rs".to_string(),
813         header:    "diff --git a/test.rs b/test.rs".to_string(),
814         content:   "+new line".to_string(),
815         additions: 1,
816         deletions: 0,
817         is_binary: false,
818      }];
819      let result = reconstruct_diff(&files);
820      assert_eq!(result, "diff --git a/test.rs b/test.rs\n+new line");
821   }
822
823   #[test]
824   fn test_reconstruct_diff_multiple_files() {
825      let files = vec![
826         FileDiff {
827            filename:  "a.rs".to_string(),
828            header:    "diff --git a/a.rs b/a.rs".to_string(),
829            content:   "+line a".to_string(),
830            additions: 1,
831            deletions: 0,
832            is_binary: false,
833         },
834         FileDiff {
835            filename:  "b.rs".to_string(),
836            header:    "diff --git a/b.rs b/b.rs".to_string(),
837            content:   "+line b".to_string(),
838            additions: 1,
839            deletions: 0,
840            is_binary: false,
841         },
842      ];
843      let result = reconstruct_diff(&files);
844      assert!(result.contains("a.rs"));
845      assert!(result.contains("b.rs"));
846      assert!(result.contains("+line a"));
847      assert!(result.contains("+line b"));
848   }
849
850   #[test]
851   fn test_reconstruct_diff_empty_content() {
852      let files = vec![FileDiff {
853         filename:  "test.rs".to_string(),
854         header:    "diff --git a/test.rs b/test.rs".to_string(),
855         content:   String::new(),
856         additions: 0,
857         deletions: 0,
858         is_binary: false,
859      }];
860      let result = reconstruct_diff(&files);
861      assert_eq!(result, "diff --git a/test.rs b/test.rs");
862   }
863
864   #[test]
865   fn test_reconstruct_diff_empty_vec() {
866      let files: Vec<FileDiff> = vec![];
867      let result = reconstruct_diff(&files);
868      assert_eq!(result, "");
869   }
870}