llm_git/
diff.rs

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