Skip to main content

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