1use std::collections::{HashMap, HashSet};
20use std::process::Command;
21
22use crate::analytics::Analytics;
23use crate::db::Database;
24use crate::error::{CtxError, Result};
25use crate::tokens::{count_tokens_with_encoding, select_by_token_budget, Encoding, HasTokenCount};
26
27#[derive(Debug, Clone)]
29pub struct DiffConfig {
30 pub max_tokens: usize,
32 pub depth: i32,
34 pub changes_only: bool,
36 pub staged: bool,
38 #[allow(dead_code)] pub summary: bool,
41 pub encoding: Encoding,
43}
44
45impl Default for DiffConfig {
46 fn default() -> Self {
47 Self {
48 max_tokens: 8000,
49 depth: 1,
50 changes_only: false,
51 staged: false,
52 summary: false,
53 encoding: Encoding::default(),
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ChangeType {
61 Added,
63 Modified,
65 Deleted,
67 Renamed,
69 Copied,
71 Unknown,
73}
74
75impl ChangeType {
76 pub fn from_git_status(status: char) -> Self {
78 match status {
79 'A' => ChangeType::Added,
80 'M' => ChangeType::Modified,
81 'D' => ChangeType::Deleted,
82 'R' => ChangeType::Renamed,
83 'C' => ChangeType::Copied,
84 _ => ChangeType::Unknown,
85 }
86 }
87
88 pub fn as_str(&self) -> &'static str {
90 match self {
91 ChangeType::Added => "added",
92 ChangeType::Modified => "modified",
93 ChangeType::Deleted => "deleted",
94 ChangeType::Renamed => "renamed",
95 ChangeType::Copied => "copied",
96 ChangeType::Unknown => "unknown",
97 }
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct ChangedFile {
104 pub path: String,
106 pub change_type: ChangeType,
108 pub lines_added: usize,
110 pub lines_removed: usize,
112 pub changed_ranges: Vec<(u32, u32)>,
114 pub original_path: Option<String>,
116}
117
118impl ChangedFile {
119 pub fn new(path: String, change_type: ChangeType) -> Self {
121 Self {
122 path,
123 change_type,
124 lines_added: 0,
125 lines_removed: 0,
126 changed_ranges: Vec::new(),
127 original_path: None,
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
134pub struct ContextFile {
135 pub path: String,
137 pub priority: f32,
139 pub reason: ContextReason,
141 pub token_count: usize,
143}
144
145impl HasTokenCount for ContextFile {
146 fn token_count(&self) -> usize {
147 self.token_count
148 }
149}
150
151#[derive(Debug, Clone)]
153#[allow(dead_code)] pub enum ContextReason {
155 Changed(ChangeType),
157 CallsChanged { symbol: String, depth: i32 },
159 CalledByChanged { symbol: String, depth: i32 },
161}
162
163impl ContextReason {
164 #[allow(dead_code)] pub fn description(&self) -> String {
167 match self {
168 ContextReason::Changed(ct) => format!("changed ({})", ct.as_str()),
169 ContextReason::CallsChanged { symbol, depth } => {
170 format!("calls changed symbol '{}' (depth {})", symbol, depth)
171 }
172 ContextReason::CalledByChanged { symbol, depth } => {
173 format!("called by changed symbol '{}' (depth {})", symbol, depth)
174 }
175 }
176 }
177}
178
179#[derive(Debug, Clone)]
181pub struct AffectedSymbol {
182 pub name: String,
184 pub file_path: String,
186 #[allow(dead_code)] pub line: u32,
189 pub kind: String,
191}
192
193#[derive(Debug, Clone)]
195pub struct DiffContext {
196 pub revision: String,
198 pub changed_files: Vec<ChangedFile>,
200 pub affected_symbols: Vec<AffectedSymbol>,
202 pub context_files: Vec<ContextFile>,
204 pub total_tokens: usize,
206 pub truncated: bool,
208 pub omitted_count: usize,
210}
211
212pub fn is_git_repo() -> bool {
214 Command::new("git")
215 .args(["rev-parse", "--git-dir"])
216 .output()
217 .map(|o| o.status.success())
218 .unwrap_or(false)
219}
220
221pub fn get_changed_files(revision: &str, staged: bool) -> Result<Vec<ChangedFile>> {
223 if !is_git_repo() {
224 return Err(CtxError::NotGitRepo);
225 }
226
227 let mut args = vec!["diff", "--name-status"];
229
230 if staged {
231 args.push("--staged");
232 } else {
233 args.push(revision);
234 }
235
236 let output = Command::new("git").args(&args).output()?;
237
238 if !output.status.success() {
239 let stderr = String::from_utf8_lossy(&output.stderr);
240 if stderr.contains("unknown revision") || stderr.contains("bad revision") {
241 return Err(CtxError::InvalidRevision(revision.to_string()));
242 }
243 return Err(CtxError::git(stderr.to_string()));
244 }
245
246 let stdout = String::from_utf8_lossy(&output.stdout);
247 let files = parse_diff_output(&stdout);
248
249 if files.is_empty() {
250 return Err(CtxError::NoChanges);
251 }
252
253 let mut detailed_files = Vec::new();
255 for mut file in files {
256 if file.change_type != ChangeType::Deleted {
257 if let Ok(ranges) = get_diff_line_ranges(revision, &file.path, staged) {
258 file.changed_ranges = ranges;
259 }
260 if let Ok((added, removed)) = get_diff_stats(revision, &file.path, staged) {
261 file.lines_added = added;
262 file.lines_removed = removed;
263 }
264 }
265 detailed_files.push(file);
266 }
267
268 Ok(detailed_files)
269}
270
271pub fn parse_diff_output(output: &str) -> Vec<ChangedFile> {
273 let mut files = Vec::new();
274
275 for line in output.lines() {
276 let line = line.trim();
277 if line.is_empty() {
278 continue;
279 }
280
281 let parts: Vec<&str> = line.split('\t').collect();
283 if parts.is_empty() {
284 continue;
285 }
286
287 let status_str = parts[0];
288 let status_char = status_str.chars().next().unwrap_or('?');
289 let change_type = ChangeType::from_git_status(status_char);
290
291 let (path, original_path) = if change_type == ChangeType::Renamed && parts.len() >= 3 {
292 (parts[2].to_string(), Some(parts[1].to_string()))
293 } else if parts.len() >= 2 {
294 (parts[1].to_string(), None)
295 } else {
296 continue;
297 };
298
299 let mut file = ChangedFile::new(path, change_type);
300 file.original_path = original_path;
301 files.push(file);
302 }
303
304 files
305}
306
307fn get_diff_line_ranges(revision: &str, path: &str, staged: bool) -> Result<Vec<(u32, u32)>> {
309 let mut args = vec!["diff", "-U0", "--no-color"];
310
311 if staged {
312 args.push("--staged");
313 } else {
314 args.push(revision);
315 }
316
317 args.push("--");
318 args.push(path);
319
320 let output = Command::new("git").args(&args).output()?;
321
322 if !output.status.success() {
323 return Ok(Vec::new());
324 }
325
326 let stdout = String::from_utf8_lossy(&output.stdout);
327 Ok(parse_diff_hunks(&stdout))
328}
329
330fn parse_diff_hunks(diff_output: &str) -> Vec<(u32, u32)> {
332 let mut ranges = Vec::new();
333
334 for line in diff_output.lines() {
335 if line.starts_with("@@") {
337 if let Some(new_range) = parse_hunk_header(line) {
338 ranges.push(new_range);
339 }
340 }
341 }
342
343 ranges
344}
345
346fn parse_hunk_header(header: &str) -> Option<(u32, u32)> {
348 let parts: Vec<&str> = header.split_whitespace().collect();
350
351 for part in parts {
352 if part.starts_with('+') && !part.starts_with("+++") {
353 let range_str = &part[1..];
354 let range_parts: Vec<&str> = range_str.split(',').collect();
355
356 let start: u32 = range_parts.first()?.parse().ok()?;
357 let count: u32 = range_parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(1);
358
359 if count == 0 {
360 return None; }
362
363 return Some((start, start + count - 1));
364 }
365 }
366
367 None
368}
369
370fn get_diff_stats(revision: &str, path: &str, staged: bool) -> Result<(usize, usize)> {
372 let mut args = vec!["diff", "--numstat"];
373
374 if staged {
375 args.push("--staged");
376 } else {
377 args.push(revision);
378 }
379
380 args.push("--");
381 args.push(path);
382
383 let output = Command::new("git").args(&args).output()?;
384
385 if !output.status.success() {
386 return Ok((0, 0));
387 }
388
389 let stdout = String::from_utf8_lossy(&output.stdout);
390 for line in stdout.lines() {
391 let parts: Vec<&str> = line.split_whitespace().collect();
392 if parts.len() >= 2 {
393 let added: usize = parts[0].parse().unwrap_or(0);
394 let removed: usize = parts[1].parse().unwrap_or(0);
395 return Ok((added, removed));
396 }
397 }
398
399 Ok((0, 0))
400}
401
402pub fn find_symbols_in_lines(
404 db: &Database,
405 file_path: &str,
406 changed_ranges: &[(u32, u32)],
407) -> Result<Vec<AffectedSymbol>> {
408 let symbols = db.find_symbols_in_file(file_path)?;
409
410 let mut affected = Vec::new();
411
412 for symbol in symbols {
413 for &(start, end) in changed_ranges {
415 if ranges_overlap(symbol.line_start, symbol.line_end, start, end) {
416 affected.push(AffectedSymbol {
417 name: symbol.name.clone(),
418 file_path: symbol.file_path.clone(),
419 line: symbol.line_start,
420 kind: symbol.kind.as_str().to_string(),
421 });
422 break;
423 }
424 }
425 }
426
427 Ok(affected)
428}
429
430fn ranges_overlap(s1_start: u32, s1_end: u32, s2_start: u32, s2_end: u32) -> bool {
432 s1_start <= s2_end && s2_start <= s1_end
433}
434
435pub fn diff_context(
437 revision: &str,
438 db: &Database,
439 analytics: &Analytics,
440 config: DiffConfig,
441) -> Result<DiffContext> {
442 let changed_files = get_changed_files(revision, config.staged)?;
444
445 let mut affected_symbols = Vec::new();
447 for file in &changed_files {
448 if file.change_type != ChangeType::Deleted && !file.changed_ranges.is_empty() {
449 if let Ok(symbols) = find_symbols_in_lines(db, &file.path, &file.changed_ranges) {
450 affected_symbols.extend(symbols);
451 }
452 }
453 }
454
455 let mut context_files: HashMap<String, ContextFile> = HashMap::new();
457
458 for file in &changed_files {
460 if file.change_type != ChangeType::Deleted {
461 context_files.insert(
462 file.path.clone(),
463 ContextFile {
464 path: file.path.clone(),
465 priority: 1.0,
466 reason: ContextReason::Changed(file.change_type),
467 token_count: 0,
468 },
469 );
470 }
471 }
472
473 if !config.changes_only {
475 for symbol in &affected_symbols {
476 expand_context_for_symbol(&mut context_files, analytics, symbol, config.depth);
477 }
478 }
479
480 let root = std::env::current_dir().unwrap_or_default();
482 for ctx_file in context_files.values_mut() {
483 let path = root.join(&ctx_file.path);
484 if let Ok(content) = std::fs::read_to_string(&path) {
485 ctx_file.token_count =
486 count_tokens_with_encoding(&content, config.encoding).unwrap_or(0);
487 }
488 }
489
490 let mut files: Vec<ContextFile> = context_files.into_values().collect();
492 files.sort_by(|a, b| {
493 b.priority
494 .partial_cmp(&a.priority)
495 .unwrap_or(std::cmp::Ordering::Equal)
496 });
497
498 let (selected, total_tokens, omitted) = select_by_token_budget(files, config.max_tokens);
499
500 Ok(DiffContext {
501 revision: revision.to_string(),
502 changed_files,
503 affected_symbols,
504 context_files: selected,
505 total_tokens,
506 truncated: omitted > 0,
507 omitted_count: omitted,
508 })
509}
510
511fn expand_context_for_symbol(
513 context_files: &mut HashMap<String, ContextFile>,
514 analytics: &Analytics,
515 symbol: &AffectedSymbol,
516 depth: i32,
517) {
518 if let Ok(callers) = analytics.impact_analysis(&symbol.name, depth) {
520 for caller in callers {
521 let priority = 0.8 / (caller.distance as f32).max(1.0);
522 add_context_file(
523 context_files,
524 &caller.file_path,
525 priority,
526 ContextReason::CalledByChanged {
527 symbol: symbol.name.clone(),
528 depth: caller.distance,
529 },
530 );
531 }
532 }
533
534 if let Ok(callees) = analytics.call_graph(&symbol.name, depth) {
536 for callee in callees {
537 let priority = 0.6 / (callee.depth as f32).max(1.0);
538 add_context_file(
539 context_files,
540 &callee.file_path,
541 priority,
542 ContextReason::CallsChanged {
543 symbol: symbol.name.clone(),
544 depth: callee.depth,
545 },
546 );
547 }
548 }
549}
550
551fn add_context_file(
553 context_files: &mut HashMap<String, ContextFile>,
554 path: &str,
555 priority: f32,
556 reason: ContextReason,
557) {
558 if let Some(existing) = context_files.get_mut(path) {
559 if priority > existing.priority {
561 existing.priority = priority;
562 existing.reason = reason;
563 }
564 } else {
565 context_files.insert(
566 path.to_string(),
567 ContextFile {
568 path: path.to_string(),
569 priority,
570 reason,
571 token_count: 0,
572 },
573 );
574 }
575}
576
577pub fn format_summary(context: &DiffContext) -> String {
579 let mut output = String::new();
580
581 output.push_str(&format!(
582 "Changes in {}: {} files changed\n\n",
583 context.revision,
584 context.changed_files.len()
585 ));
586
587 output.push_str("Changed files:\n");
588 for file in &context.changed_files {
589 let stats = if file.lines_added > 0 || file.lines_removed > 0 {
590 format!("+{} -{}", file.lines_added, file.lines_removed)
591 } else {
592 file.change_type.as_str().to_string()
593 };
594 output.push_str(&format!(" {} ({})\n", file.path, stats));
595 }
596
597 if !context.affected_symbols.is_empty() {
598 output.push_str("\nAffected symbols:\n");
599 let mut seen = HashSet::new();
600 for symbol in &context.affected_symbols {
601 let key = format!("{}:{}", symbol.file_path, symbol.name);
602 if seen.insert(key) {
603 output.push_str(&format!(
604 " {} ({}) in {}\n",
605 symbol.name, symbol.kind, symbol.file_path
606 ));
607 }
608 }
609 }
610
611 output.push_str(&format!(
612 "\nContext: {} files ({} tokens)",
613 context.context_files.len(),
614 context.total_tokens
615 ));
616
617 if context.truncated {
618 output.push_str(&format!(", {} omitted", context.omitted_count));
619 }
620
621 output.push('\n');
622 output
623}
624
625#[derive(Debug, Clone)]
627pub struct PrInfo {
628 pub number: u64,
630 pub title: String,
632 pub body: String,
634 pub author: String,
636 pub base: String,
638 pub head: String,
640 pub files: Vec<PrFile>,
642 pub comments: Vec<PrComment>,
644}
645
646#[derive(Debug, Clone)]
648pub struct PrFile {
649 pub path: String,
651 pub additions: usize,
653 pub deletions: usize,
655}
656
657#[derive(Debug, Clone)]
659pub struct PrComment {
660 pub author: String,
662 pub body: String,
664 pub path: Option<String>,
666 pub line: Option<u32>,
668}
669
670pub fn get_pr_info(pr: &str, repo: Option<&str>) -> Result<PrInfo> {
672 let mut args = vec![
673 "pr",
674 "view",
675 pr,
676 "--json",
677 "number,title,body,author,baseRefName,headRefName,files,comments",
678 ];
679
680 if let Some(r) = repo {
681 args.push("--repo");
682 args.push(r);
683 }
684
685 let output = Command::new("gh").args(&args).output().map_err(|e| {
686 if e.kind() == std::io::ErrorKind::NotFound {
687 CtxError::git(
688 "GitHub CLI (gh) not found. Install it from https://cli.github.com/".to_string(),
689 )
690 } else {
691 CtxError::Io(e)
692 }
693 })?;
694
695 if !output.status.success() {
696 let stderr = String::from_utf8_lossy(&output.stderr);
697 if stderr.contains("Could not resolve") || stderr.contains("not found") {
698 return Err(CtxError::InvalidRevision(format!("PR #{} not found", pr)));
699 }
700 return Err(CtxError::git(stderr.to_string()));
701 }
702
703 let json: serde_json::Value = serde_json::from_slice(&output.stdout)
705 .map_err(|e| CtxError::git(format!("Failed to parse gh output: {}", e)))?;
706
707 Ok(PrInfo {
708 number: json["number"].as_u64().unwrap_or(0),
709 title: json["title"].as_str().unwrap_or("").to_string(),
710 body: json["body"].as_str().unwrap_or("").to_string(),
711 author: json["author"]["login"].as_str().unwrap_or("").to_string(),
712 base: json["baseRefName"].as_str().unwrap_or("main").to_string(),
713 head: json["headRefName"].as_str().unwrap_or("").to_string(),
714 files: parse_pr_files(&json["files"]),
715 comments: parse_pr_comments(&json["comments"]),
716 })
717}
718
719fn parse_pr_files(json: &serde_json::Value) -> Vec<PrFile> {
721 let mut files = Vec::new();
722 if let Some(arr) = json.as_array() {
723 for item in arr {
724 files.push(PrFile {
725 path: item["path"].as_str().unwrap_or("").to_string(),
726 additions: item["additions"].as_u64().unwrap_or(0) as usize,
727 deletions: item["deletions"].as_u64().unwrap_or(0) as usize,
728 });
729 }
730 }
731 files
732}
733
734fn parse_pr_comments(json: &serde_json::Value) -> Vec<PrComment> {
736 let mut comments = Vec::new();
737 if let Some(arr) = json.as_array() {
738 for item in arr {
739 comments.push(PrComment {
740 author: item["author"]["login"].as_str().unwrap_or("").to_string(),
741 body: item["body"].as_str().unwrap_or("").to_string(),
742 path: item["path"].as_str().map(|s| s.to_string()),
743 line: item["line"].as_u64().map(|n| n as u32),
744 });
745 }
746 }
747 comments
748}
749
750pub fn format_pr_header(pr: &PrInfo, include_comments: bool) -> String {
752 let mut output = String::new();
753
754 output.push_str(&format!(
755 "PR #{}: {} (by @{})\n",
756 pr.number, pr.title, pr.author
757 ));
758 output.push_str(&format!("Branch: {} -> {}\n", pr.head, pr.base));
759
760 if !pr.body.is_empty() {
761 output.push_str("\nDescription:\n");
762 for line in pr.body.lines().take(10) {
763 output.push_str(&format!(" {}\n", line));
764 }
765 if pr.body.lines().count() > 10 {
766 output.push_str(" ...\n");
767 }
768 }
769
770 output.push_str(&format!("\nFiles changed: {}\n", pr.files.len()));
771 for file in &pr.files {
772 output.push_str(&format!(
773 " {} (+{} -{})\n",
774 file.path, file.additions, file.deletions
775 ));
776 }
777
778 if include_comments && !pr.comments.is_empty() {
779 output.push_str(&format!("\nComments ({}):\n", pr.comments.len()));
780 for comment in &pr.comments {
781 let location = match (&comment.path, comment.line) {
782 (Some(p), Some(l)) => format!(" at {}:{}", p, l),
783 _ => String::new(),
784 };
785 output.push_str(&format!(" @{}{}: ", comment.author, location));
786 let preview: String = comment.body.chars().take(80).collect();
787 output.push_str(&preview);
788 if comment.body.len() > 80 {
789 output.push_str("...");
790 }
791 output.push('\n');
792 }
793 }
794
795 output
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 #[test]
803 fn test_change_type_from_git_status() {
804 assert_eq!(ChangeType::from_git_status('A'), ChangeType::Added);
805 assert_eq!(ChangeType::from_git_status('M'), ChangeType::Modified);
806 assert_eq!(ChangeType::from_git_status('D'), ChangeType::Deleted);
807 assert_eq!(ChangeType::from_git_status('R'), ChangeType::Renamed);
808 assert_eq!(ChangeType::from_git_status('C'), ChangeType::Copied);
809 assert_eq!(ChangeType::from_git_status('X'), ChangeType::Unknown);
810 }
811
812 #[test]
813 fn test_parse_diff_output() {
814 let output = "M\tsrc/main.rs\nA\tsrc/new.rs\nD\tsrc/old.rs\n";
815 let files = parse_diff_output(output);
816
817 assert_eq!(files.len(), 3);
818 assert_eq!(files[0].path, "src/main.rs");
819 assert_eq!(files[0].change_type, ChangeType::Modified);
820 assert_eq!(files[1].path, "src/new.rs");
821 assert_eq!(files[1].change_type, ChangeType::Added);
822 assert_eq!(files[2].path, "src/old.rs");
823 assert_eq!(files[2].change_type, ChangeType::Deleted);
824 }
825
826 #[test]
827 fn test_parse_diff_output_rename() {
828 let output = "R100\tsrc/old_name.rs\tsrc/new_name.rs\n";
829 let files = parse_diff_output(output);
830
831 assert_eq!(files.len(), 1);
832 assert_eq!(files[0].path, "src/new_name.rs");
833 assert_eq!(files[0].change_type, ChangeType::Renamed);
834 assert_eq!(files[0].original_path, Some("src/old_name.rs".to_string()));
835 }
836
837 #[test]
838 fn test_parse_hunk_header() {
839 assert_eq!(parse_hunk_header("@@ -1,5 +1,7 @@ fn main"), Some((1, 7)));
841
842 assert_eq!(parse_hunk_header("@@ -1 +1 @@"), Some((1, 1)));
844
845 assert_eq!(parse_hunk_header("@@ -10,0 +11,5 @@"), Some((11, 15)));
847
848 assert_eq!(parse_hunk_header("@@ -1,5 +1,0 @@"), None);
850 }
851
852 #[test]
853 fn test_parse_diff_hunks() {
854 let diff = r#"diff --git a/src/main.rs b/src/main.rs
855index abc123..def456 100644
856--- a/src/main.rs
857+++ b/src/main.rs
858@@ -1,5 +1,7 @@
859 fn main() {
860+ // New comment
861+ setup();
862 run();
863@@ -10,3 +12,5 @@
864 fn helper() {
865+ // More changes
866"#;
867 let ranges = parse_diff_hunks(diff);
868 assert_eq!(ranges.len(), 2);
869 assert_eq!(ranges[0], (1, 7));
870 assert_eq!(ranges[1], (12, 16));
871 }
872
873 #[test]
874 fn test_ranges_overlap() {
875 assert!(ranges_overlap(1, 10, 5, 15));
877 assert!(ranges_overlap(5, 15, 1, 10));
878
879 assert!(ranges_overlap(1, 5, 5, 10));
881
882 assert!(!ranges_overlap(1, 5, 6, 10));
884 assert!(!ranges_overlap(6, 10, 1, 5));
885 }
886
887 #[test]
888 fn test_context_reason_description() {
889 let changed = ContextReason::Changed(ChangeType::Modified);
890 assert!(changed.description().contains("modified"));
891
892 let calls = ContextReason::CallsChanged {
893 symbol: "foo".to_string(),
894 depth: 2,
895 };
896 assert!(calls.description().contains("foo"));
897 assert!(calls.description().contains("2"));
898 }
899
900 #[test]
901 fn test_select_by_token_budget() {
902 let files = vec![
903 ContextFile {
904 path: "a.rs".to_string(),
905 priority: 1.0,
906 reason: ContextReason::Changed(ChangeType::Modified),
907 token_count: 100,
908 },
909 ContextFile {
910 path: "b.rs".to_string(),
911 priority: 0.8,
912 reason: ContextReason::Changed(ChangeType::Added),
913 token_count: 200,
914 },
915 ContextFile {
916 path: "c.rs".to_string(),
917 priority: 0.6,
918 reason: ContextReason::Changed(ChangeType::Modified),
919 token_count: 150,
920 },
921 ];
922
923 let (selected, total, omitted) = select_by_token_budget(files.clone(), 500);
925 assert_eq!(selected.len(), 3);
926 assert_eq!(total, 450);
927 assert_eq!(omitted, 0);
928
929 let (selected, total, omitted) = select_by_token_budget(files.clone(), 300);
931 assert_eq!(selected.len(), 2);
932 assert_eq!(total, 300);
933 assert_eq!(omitted, 1);
934 }
935
936 #[test]
937 fn test_diff_config_default() {
938 let config = DiffConfig::default();
939 assert_eq!(config.max_tokens, 8000);
940 assert_eq!(config.depth, 1);
941 assert!(!config.changes_only);
942 assert!(!config.staged);
943 assert!(!config.summary);
944 }
945}