1use crate::{config::CommitConfig, tokens::TokenCounter};
3
4#[derive(Debug, Clone)]
5pub struct FileDiff {
6 pub filename: String,
7 pub header: String, pub content: String, 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 token_estimate(&self, counter: &TokenCounter) -> usize {
21 counter.count_sync(&self.header) + counter.count_sync(&self.content)
23 }
24
25 pub fn priority(&self, config: &CommitConfig) -> i32 {
26 if self.is_binary {
28 return -100; }
30
31 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; }
41
42 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 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 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 let available = max_size.saturating_sub(self.header.len() + 50); if available < 50 {
78 self.content = "... (truncated)".to_string();
80 } else {
81 let lines: Vec<&str> = self.content.lines().collect();
83 if lines.len() > 30 {
84 let keep_start = 15;
86 let keep_end = 10;
87 let omitted = lines.len() - keep_start - keep_end;
88 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 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
119pub 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 if let Some(file) = current_file.take() {
129 file_diffs.push(file);
130 }
131
132 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 file.header.reserve(line.len() + 1);
164 file.header.push('\n');
165 file.header.push_str(line);
166 } else if line.starts_with("@@") {
167 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 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 file.header.reserve(line.len() + 1);
187 file.header.push('\n');
188 file.header.push_str(line);
189 }
190 }
191 }
192
193 if let Some(file) = current_file {
195 file_diffs.push(file);
196 }
197
198 file_diffs
199}
200
201pub 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 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 file_diffs.sort_by_key(|f| -f.priority(config));
225
226 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 let effective_max = if total_tokens > config.max_diff_tokens {
233 config.max_diff_tokens * 4
235 } else {
236 max_length
237 };
238
239 if total_size <= effective_max {
240 return reconstruct_diff(&file_diffs);
242 }
243
244 let mut included_files = Vec::new();
247 let mut current_size = 0;
248
249 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 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 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 for mut file in file_diffs {
286 if file.is_binary {
287 continue; }
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 let remaining = effective_max - current_size;
298 file.truncate(remaining.saturating_sub(100)); 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 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
321pub fn reconstruct_diff(files: &[FileDiff]) -> String {
323 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
341pub fn truncate_diff_by_lines(diff: &str, max_lines: usize, config: &CommitConfig) -> String {
347 let files = parse_diff(diff);
348
349 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 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 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 #[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); 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 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); }
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")); assert!(file.content.contains("line 99")); }
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 file.truncate(300);
776 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 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); 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 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 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 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}