1use super::types::{DepGraph, FileEntry, IndexSymbol, IndexSymbolKind, SymbolIndex};
7use std::collections::{HashSet, VecDeque};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ContextDepth {
12 L1,
14 #[default]
16 L2,
17 L3,
19}
20
21#[derive(Debug, Clone)]
23pub struct DiffChange {
24 pub file_path: String,
26 pub old_path: Option<String>,
28 pub line_ranges: Vec<(u32, u32)>,
30 pub change_type: ChangeType,
32 pub diff_content: Option<String>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ChangeType {
39 Added,
40 Modified,
41 Deleted,
42 Renamed,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ChangeClassification {
48 NewCode,
50 SignatureChange,
52 TypeDefinitionChange,
54 ImplementationChange,
56 Deletion,
58 FileRename,
60 ImportChange,
62 DocumentationOnly,
64}
65
66#[derive(Debug, Clone)]
68pub struct ExpandedContext {
69 pub changed_symbols: Vec<ContextSymbol>,
71 pub changed_files: Vec<ContextFile>,
73 pub dependent_symbols: Vec<ContextSymbol>,
75 pub dependent_files: Vec<ContextFile>,
77 pub related_tests: Vec<ContextFile>,
79 pub call_chains: Vec<CallChain>,
81 pub impact_summary: ImpactSummary,
83 pub total_tokens: u32,
85}
86
87#[derive(Debug, Clone)]
89pub struct ContextSymbol {
90 pub id: u32,
92 pub name: String,
94 pub kind: String,
96 pub file_path: String,
98 pub start_line: u32,
100 pub end_line: u32,
102 pub signature: Option<String>,
104 pub relevance_reason: String,
106 pub relevance_score: f32,
108}
109
110#[derive(Debug, Clone)]
112pub struct ContextFile {
113 pub id: u32,
115 pub path: String,
117 pub language: String,
119 pub relevance_reason: String,
121 pub relevance_score: f32,
123 pub tokens: u32,
125 pub relevant_sections: Vec<(u32, u32)>,
127 pub diff_content: Option<String>,
129 pub snippets: Vec<ContextSnippet>,
131}
132
133#[derive(Debug, Clone)]
135pub struct ContextSnippet {
136 pub start_line: u32,
138 pub end_line: u32,
140 pub reason: String,
142 pub content: String,
144}
145
146#[derive(Debug, Clone)]
148pub struct CallChain {
149 pub symbols: Vec<String>,
151 pub files: Vec<String>,
153}
154
155#[derive(Debug, Clone, Default)]
157pub struct ImpactSummary {
158 pub level: ImpactLevel,
160 pub direct_files: usize,
162 pub transitive_files: usize,
164 pub affected_symbols: usize,
166 pub affected_tests: usize,
168 pub breaking_changes: Vec<String>,
170 pub description: String,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub enum ImpactLevel {
177 #[default]
178 Low,
179 Medium,
180 High,
181 Critical,
182}
183
184impl ImpactLevel {
185 pub fn name(&self) -> &'static str {
186 match self {
187 Self::Low => "low",
188 Self::Medium => "medium",
189 Self::High => "high",
190 Self::Critical => "critical",
191 }
192 }
193}
194
195pub struct ContextExpander<'a> {
197 index: &'a SymbolIndex,
198 graph: &'a DepGraph,
199}
200
201impl<'a> ContextExpander<'a> {
202 pub fn new(index: &'a SymbolIndex, graph: &'a DepGraph) -> Self {
204 Self { index, graph }
205 }
206
207 pub fn classify_change(
212 &self,
213 change: &DiffChange,
214 symbol: Option<&IndexSymbol>,
215 ) -> ChangeClassification {
216 if change.change_type == ChangeType::Deleted {
218 return ChangeClassification::Deletion;
219 }
220 if change.change_type == ChangeType::Renamed {
221 return ChangeClassification::FileRename;
222 }
223 if change.change_type == ChangeType::Added {
224 return ChangeClassification::NewCode;
225 }
226
227 if let Some(diff) = &change.diff_content {
229 let signature_indicators = [
231 "fn ",
232 "def ",
233 "function ",
234 "func ",
235 "pub fn ",
236 "async fn ",
237 "class ",
238 "struct ",
239 "enum ",
240 "interface ",
241 "type ",
242 "trait ",
243 ];
244 let has_signature_change = diff.lines().any(|line| {
245 let trimmed = line.trim_start_matches(['+', '-', ' ']);
246 signature_indicators
247 .iter()
248 .any(|ind| trimmed.starts_with(ind))
249 });
250
251 if has_signature_change {
252 let type_indicators =
254 ["class ", "struct ", "enum ", "interface ", "type ", "trait "];
255 if diff.lines().any(|line| {
256 let trimmed = line.trim_start_matches(['+', '-', ' ']);
257 type_indicators.iter().any(|ind| trimmed.starts_with(ind))
258 }) {
259 return ChangeClassification::TypeDefinitionChange;
260 }
261 return ChangeClassification::SignatureChange;
262 }
263
264 let import_indicators = ["import ", "from ", "require(", "use ", "#include"];
266 if diff.lines().any(|line| {
267 let trimmed = line.trim_start_matches(['+', '-', ' ']);
268 import_indicators.iter().any(|ind| trimmed.starts_with(ind))
269 }) {
270 return ChangeClassification::ImportChange;
271 }
272
273 let doc_indicators = ["///", "//!", "/**", "/*", "#", "\"\"\"", "'''"];
275 let all_doc_changes = diff.lines()
276 .filter(|l| l.starts_with('+') || l.starts_with('-'))
277 .filter(|l| l.len() > 1) .all(|line| {
279 let trimmed = line[1..].trim();
280 trimmed.is_empty() || doc_indicators.iter().any(|ind| trimmed.starts_with(ind))
281 });
282 if all_doc_changes {
283 return ChangeClassification::DocumentationOnly;
284 }
285 }
286
287 if let Some(sym) = symbol {
289 match sym.kind {
290 IndexSymbolKind::Class
291 | IndexSymbolKind::Struct
292 | IndexSymbolKind::Enum
293 | IndexSymbolKind::Interface
294 | IndexSymbolKind::Trait
295 | IndexSymbolKind::TypeAlias => {
296 return ChangeClassification::TypeDefinitionChange;
297 },
298 IndexSymbolKind::Function | IndexSymbolKind::Method => {
299 return ChangeClassification::ImplementationChange;
301 },
302 _ => {},
303 }
304 }
305
306 ChangeClassification::ImplementationChange
308 }
309
310 fn classification_score_multiplier(&self, classification: ChangeClassification) -> f32 {
312 match classification {
313 ChangeClassification::Deletion => 1.5, ChangeClassification::SignatureChange => 1.3, ChangeClassification::TypeDefinitionChange => 1.2, ChangeClassification::FileRename => 1.1, ChangeClassification::ImportChange => 0.9, ChangeClassification::NewCode => 0.8, ChangeClassification::ImplementationChange => 0.7, ChangeClassification::DocumentationOnly => 0.3, }
322 }
323
324 fn get_caller_count(&self, symbol_id: u32) -> usize {
326 self.graph.get_callers(symbol_id).len() + self.graph.get_referencers(symbol_id).len()
327 }
328
329 pub fn expand(
331 &self,
332 changes: &[DiffChange],
333 depth: ContextDepth,
334 token_budget: u32,
335 ) -> ExpandedContext {
336 let mut changed_symbols = Vec::new();
337 let mut changed_files = Vec::new();
338 let mut dependent_symbols = Vec::new();
339 let mut dependent_files = Vec::new();
340 let mut related_tests = Vec::new();
341 let mut call_chains = Vec::new();
342
343 let mut seen_files: HashSet<u32> = HashSet::new();
344 let mut seen_symbols: HashSet<u32> = HashSet::new();
345 let mut change_classifications: Vec<ChangeClassification> = Vec::new();
346 let mut high_impact_symbols: HashSet<u32> = HashSet::new(); let mut path_overrides: std::collections::HashMap<u32, String> =
350 std::collections::HashMap::new();
351
352 for change in changes {
353 let (file, output_path) = if let Some(file) = self.index.get_file(&change.file_path) {
354 (file, change.file_path.clone())
355 } else if let Some(old_path) = &change.old_path {
356 if let Some(file) = self.index.get_file(old_path) {
357 path_overrides.insert(file.id.as_u32(), change.file_path.clone());
358 (file, change.file_path.clone())
359 } else {
360 continue;
361 }
362 } else {
363 continue;
364 };
365
366 if !seen_files.contains(&file.id.as_u32()) {
367 seen_files.insert(file.id.as_u32());
368 }
369
370 for (start, end) in &change.line_ranges {
372 for line in *start..=*end {
373 if let Some(symbol) = self.index.find_symbol_at_line(file.id, line) {
374 if !seen_symbols.contains(&symbol.id.as_u32()) {
375 seen_symbols.insert(symbol.id.as_u32());
376
377 let classification = self.classify_change(change, Some(symbol));
379 change_classifications.push(classification);
380
381 let caller_count = self.get_caller_count(symbol.id.as_u32());
386 let caller_bonus = (caller_count as f32 * 0.05).min(0.3); let base_score = 1.0 + caller_bonus;
388
389 if matches!(
391 classification,
392 ChangeClassification::SignatureChange
393 | ChangeClassification::TypeDefinitionChange
394 | ChangeClassification::Deletion
395 ) || caller_count > 5
396 {
397 high_impact_symbols.insert(symbol.id.as_u32());
398 }
399
400 let reason = match classification {
401 ChangeClassification::SignatureChange => {
402 format!("signature changed ({} callers)", caller_count)
403 },
404 ChangeClassification::TypeDefinitionChange => {
405 format!("type definition changed ({} usages)", caller_count)
406 },
407 ChangeClassification::Deletion => {
408 format!("deleted ({} callers will break)", caller_count)
409 },
410 _ => "directly modified".to_owned(),
411 };
412
413 changed_symbols.push(self.to_context_symbol(
414 symbol,
415 file,
416 &reason,
417 base_score,
418 path_overrides.get(&file.id.as_u32()).map(String::as_str),
419 ));
420 }
421 }
422 }
423 }
424
425 let file_classification = self.classify_change(change, None);
427 let file_multiplier = self.classification_score_multiplier(file_classification);
428
429 changed_files.push(ContextFile {
430 id: file.id.as_u32(),
431 path: output_path,
432 language: file.language.name().to_owned(),
433 relevance_reason: format!("{:?} ({:?})", change.change_type, file_classification),
434 relevance_score: file_multiplier,
435 tokens: file.tokens,
436 relevant_sections: change.line_ranges.clone(),
437 diff_content: change.diff_content.clone(),
438 snippets: Vec::new(),
439 });
440 }
441
442 let has_high_impact_change = change_classifications.iter().any(|c| {
444 matches!(
445 c,
446 ChangeClassification::SignatureChange
447 | ChangeClassification::TypeDefinitionChange
448 | ChangeClassification::Deletion
449 )
450 });
451
452 if depth >= ContextDepth::L2 {
454 let l2_files = self.expand_l2(&seen_files);
455 for file_id in &l2_files {
456 if !seen_files.contains(file_id) {
457 if let Some(file) = self.index.get_file_by_id(*file_id) {
458 seen_files.insert(*file_id);
459 let score = if has_high_impact_change { 0.9 } else { 0.8 };
461 let reason = if has_high_impact_change {
462 "imports changed file (breaking change detected)".to_owned()
463 } else {
464 "imports changed file".to_owned()
465 };
466 dependent_files.push(ContextFile {
467 id: file.id.as_u32(),
468 path: file.path.clone(),
469 language: file.language.name().to_owned(),
470 relevance_reason: reason,
471 relevance_score: score,
472 tokens: file.tokens,
473 relevant_sections: vec![],
474 diff_content: None,
475 snippets: Vec::new(),
476 });
477 }
478 }
479 }
480
481 let l2_symbols = self.expand_symbol_refs(&seen_symbols);
483 for symbol_id in &l2_symbols {
484 if !seen_symbols.contains(symbol_id) {
485 if let Some(symbol) = self.index.get_symbol(*symbol_id) {
486 if let Some(file) = self.index.get_file_by_id(symbol.file_id.as_u32()) {
487 seen_symbols.insert(*symbol_id);
488 let is_caller_of_high_impact = high_impact_symbols
490 .iter()
491 .any(|&hi_sym| self.graph.get_callers(hi_sym).contains(symbol_id));
492 let (reason, score) = if is_caller_of_high_impact {
493 ("calls changed symbol (may break)", 0.85)
494 } else {
495 ("references changed symbol", 0.7)
496 };
497 dependent_symbols.push(self.to_context_symbol(
498 symbol,
499 file,
500 reason,
501 score,
502 path_overrides.get(&file.id.as_u32()).map(String::as_str),
503 ));
504 }
505 }
506 }
507 }
508
509 if has_high_impact_change {
511 for &hi_sym_id in &high_impact_symbols {
512 let all_callers = self.graph.get_callers(hi_sym_id);
513 for caller_id in all_callers {
514 if !seen_symbols.contains(&caller_id) {
515 if let Some(caller) = self.index.get_symbol(caller_id) {
516 if let Some(file) =
517 self.index.get_file_by_id(caller.file_id.as_u32())
518 {
519 seen_symbols.insert(caller_id);
520 dependent_symbols.push(self.to_context_symbol(
521 caller,
522 file,
523 "calls modified symbol (potential breakage)",
524 0.9, path_overrides.get(&file.id.as_u32()).map(String::as_str),
526 ));
527 }
528 }
529 }
530 }
531 }
532 }
533 }
534
535 if depth >= ContextDepth::L3 {
536 let l3_files = self.expand_l3(&seen_files);
537 for file_id in &l3_files {
538 if !seen_files.contains(file_id) {
539 if let Some(file) = self.index.get_file_by_id(*file_id) {
540 seen_files.insert(*file_id);
541 dependent_files.push(ContextFile {
542 id: file.id.as_u32(),
543 path: file.path.clone(),
544 language: file.language.name().to_owned(),
545 relevance_reason: "transitively depends on changed file".to_owned(),
546 relevance_score: 0.5,
547 tokens: file.tokens,
548 relevant_sections: vec![],
549 diff_content: None,
550 snippets: Vec::new(),
551 });
552 }
553 }
554 }
555 }
556
557 let mut seen_test_ids: HashSet<u32> = HashSet::new();
559
560 for file in &self.index.files {
562 if self.is_test_file(&file.path) {
563 let imports = self.graph.get_imports(file.id.as_u32());
564 for &imported in &imports {
565 if seen_files.contains(&imported) && !seen_test_ids.contains(&file.id.as_u32())
566 {
567 seen_test_ids.insert(file.id.as_u32());
568 related_tests.push(ContextFile {
569 id: file.id.as_u32(),
570 path: file.path.clone(),
571 language: file.language.name().to_owned(),
572 relevance_reason: "imports changed file".to_owned(),
573 relevance_score: 0.95,
574 tokens: file.tokens,
575 relevant_sections: vec![],
576 diff_content: None,
577 snippets: Vec::new(),
578 });
579 break;
580 }
581 }
582 }
583 }
584
585 for cf in &changed_files {
587 for test_id in self.find_tests_by_naming(&cf.path) {
588 if !seen_test_ids.contains(&test_id) {
589 if let Some(file) = self.index.get_file_by_id(test_id) {
590 seen_test_ids.insert(test_id);
591 related_tests.push(ContextFile {
592 id: file.id.as_u32(),
593 path: file.path.clone(),
594 language: file.language.name().to_owned(),
595 relevance_reason: "test for changed file (naming convention)"
596 .to_owned(),
597 relevance_score: 0.85,
598 tokens: file.tokens,
599 relevant_sections: vec![],
600 diff_content: None,
601 snippets: Vec::new(),
602 });
603 }
604 }
605 }
606 }
607
608 for sym in &changed_symbols {
610 let chains = self.build_call_chains(sym.id, 3);
611 call_chains.extend(chains);
612 }
613
614 let impact_summary = self.compute_impact_summary(
616 &changed_files,
617 &dependent_files,
618 &changed_symbols,
619 &dependent_symbols,
620 &related_tests,
621 );
622
623 dependent_files.sort_by(|a, b| {
626 b.relevance_score
627 .partial_cmp(&a.relevance_score)
628 .unwrap_or(std::cmp::Ordering::Equal)
629 });
630 dependent_symbols.sort_by(|a, b| {
631 b.relevance_score
632 .partial_cmp(&a.relevance_score)
633 .unwrap_or(std::cmp::Ordering::Equal)
634 });
635 related_tests.sort_by(|a, b| {
636 b.relevance_score
637 .partial_cmp(&a.relevance_score)
638 .unwrap_or(std::cmp::Ordering::Equal)
639 });
640
641 let mut running_tokens = changed_files.iter().map(|f| f.tokens).sum::<u32>();
643
644 dependent_files.retain(|f| {
646 if running_tokens + f.tokens <= token_budget {
647 running_tokens += f.tokens;
648 true
649 } else {
650 false
651 }
652 });
653
654 related_tests.retain(|f| {
656 if running_tokens + f.tokens <= token_budget {
657 running_tokens += f.tokens;
658 true
659 } else {
660 false
661 }
662 });
663
664 ExpandedContext {
665 changed_symbols,
666 changed_files,
667 dependent_symbols,
668 dependent_files,
669 related_tests,
670 call_chains,
671 impact_summary,
672 total_tokens: running_tokens,
673 }
674 }
675
676 fn expand_l2(&self, file_ids: &HashSet<u32>) -> Vec<u32> {
678 let mut result = Vec::new();
679 for &file_id in file_ids {
680 result.extend(self.graph.get_importers(file_id));
681 }
682 result
683 }
684
685 fn expand_l3(&self, file_ids: &HashSet<u32>) -> Vec<u32> {
687 let mut result = Vec::new();
688 let mut visited: HashSet<u32> = file_ids.iter().copied().collect();
689 let mut queue: VecDeque<u32> = VecDeque::new();
690
691 for &file_id in file_ids {
692 for importer in self.graph.get_importers(file_id) {
693 if visited.insert(importer) {
694 result.push(importer);
695 queue.push_back(importer);
696 }
697 }
698 }
699
700 while let Some(current) = queue.pop_front() {
701 for importer in self.graph.get_importers(current) {
702 if visited.insert(importer) {
703 result.push(importer);
704 queue.push_back(importer);
705 }
706 }
707 }
708
709 result
710 }
711
712 fn expand_symbol_refs(&self, symbol_ids: &HashSet<u32>) -> Vec<u32> {
714 let mut result = Vec::new();
715 for &symbol_id in symbol_ids {
716 result.extend(self.graph.get_referencers(symbol_id));
717 result.extend(self.graph.get_callers(symbol_id));
718 }
719 result
720 }
721
722 fn is_test_file(&self, path: &str) -> bool {
724 let path_lower = path.to_lowercase();
725 path_lower.contains("test")
726 || path_lower.contains("spec")
727 || path_lower.contains("__tests__")
728 || path_lower.ends_with("_test.rs")
729 || path_lower.ends_with("_test.go")
730 || path_lower.ends_with("_test.py")
731 || path_lower.ends_with(".test.ts")
732 || path_lower.ends_with(".test.js")
733 || path_lower.ends_with(".spec.ts")
734 || path_lower.ends_with(".spec.js")
735 }
736
737 fn find_tests_by_naming(&self, source_path: &str) -> Vec<u32> {
743 let path_lower = source_path.to_lowercase();
744 let base_name = std::path::Path::new(&path_lower)
745 .file_stem()
746 .and_then(|s| s.to_str())
747 .unwrap_or("");
748
749 let mut test_ids = Vec::new();
750
751 if base_name.is_empty() {
752 return test_ids;
753 }
754
755 let test_patterns = [
757 format!("{}_test.", base_name),
758 format!("test_{}", base_name),
759 format!("{}.test.", base_name),
760 format!("{}.spec.", base_name),
761 format!("test/{}", base_name),
762 format!("tests/{}", base_name),
763 format!("__tests__/{}", base_name),
764 ];
765
766 for file in &self.index.files {
767 let file_lower = file.path.to_lowercase();
768 if self.is_test_file(&file.path) {
769 for pattern in &test_patterns {
770 if file_lower.contains(pattern) {
771 test_ids.push(file.id.as_u32());
772 break;
773 }
774 }
775 }
776 }
777
778 test_ids
779 }
780
781 fn to_context_symbol(
783 &self,
784 symbol: &IndexSymbol,
785 file: &FileEntry,
786 reason: &str,
787 score: f32,
788 path_override: Option<&str>,
789 ) -> ContextSymbol {
790 ContextSymbol {
791 id: symbol.id.as_u32(),
792 name: symbol.name.clone(),
793 kind: symbol.kind.name().to_owned(),
794 file_path: path_override.unwrap_or(&file.path).to_owned(),
795 start_line: symbol.span.start_line,
796 end_line: symbol.span.end_line,
797 signature: symbol.signature.clone(),
798 relevance_reason: reason.to_owned(),
799 relevance_score: score,
800 }
801 }
802
803 fn build_call_chains(&self, symbol_id: u32, max_depth: usize) -> Vec<CallChain> {
805 let mut chains = Vec::new();
806
807 let mut upstream = Vec::new();
809 self.collect_callers(symbol_id, &mut upstream, max_depth, &mut HashSet::new());
810 if !upstream.is_empty() {
811 upstream.reverse();
812 if let Some(sym) = self.index.get_symbol(symbol_id) {
813 upstream.push(sym.name.clone());
814 }
815 chains.push(CallChain {
816 symbols: upstream.clone(),
817 files: self.get_files_for_symbols(&upstream),
818 });
819 }
820
821 let mut downstream = Vec::new();
823 if let Some(sym) = self.index.get_symbol(symbol_id) {
824 downstream.push(sym.name.clone());
825 }
826 self.collect_callees(symbol_id, &mut downstream, max_depth, &mut HashSet::new());
827 if downstream.len() > 1 {
828 chains.push(CallChain {
829 symbols: downstream.clone(),
830 files: self.get_files_for_symbols(&downstream),
831 });
832 }
833
834 chains
835 }
836
837 fn collect_callers(
838 &self,
839 symbol_id: u32,
840 chain: &mut Vec<String>,
841 depth: usize,
842 visited: &mut HashSet<u32>,
843 ) {
844 if depth == 0 || visited.contains(&symbol_id) {
845 return;
846 }
847 visited.insert(symbol_id);
848
849 let callers = self.graph.get_callers(symbol_id);
850 if let Some(&caller_id) = callers.first() {
851 if let Some(sym) = self.index.get_symbol(caller_id) {
852 chain.push(sym.name.clone());
853 self.collect_callers(caller_id, chain, depth - 1, visited);
854 }
855 }
856 }
857
858 fn collect_callees(
859 &self,
860 symbol_id: u32,
861 chain: &mut Vec<String>,
862 depth: usize,
863 visited: &mut HashSet<u32>,
864 ) {
865 if depth == 0 || visited.contains(&symbol_id) {
866 return;
867 }
868 visited.insert(symbol_id);
869
870 let callees = self.graph.get_callees(symbol_id);
871 if let Some(&callee_id) = callees.first() {
872 if let Some(sym) = self.index.get_symbol(callee_id) {
873 chain.push(sym.name.clone());
874 self.collect_callees(callee_id, chain, depth - 1, visited);
875 }
876 }
877 }
878
879 fn get_files_for_symbols(&self, symbol_names: &[String]) -> Vec<String> {
880 let mut files = Vec::new();
881 let mut seen = HashSet::new();
882 for name in symbol_names {
883 for sym in self.index.find_symbols(name) {
884 if let Some(file) = self.index.get_file_by_id(sym.file_id.as_u32()) {
885 if seen.insert(file.id) {
886 files.push(file.path.clone());
887 }
888 }
889 }
890 }
891 files
892 }
893
894 fn compute_impact_summary(
896 &self,
897 changed_files: &[ContextFile],
898 dependent_files: &[ContextFile],
899 changed_symbols: &[ContextSymbol],
900 dependent_symbols: &[ContextSymbol],
901 related_tests: &[ContextFile],
902 ) -> ImpactSummary {
903 let direct_files = changed_files.len();
904 let transitive_files = dependent_files.len();
905 let affected_symbols = changed_symbols.len() + dependent_symbols.len();
906 let affected_tests = related_tests.len();
907
908 let level = if transitive_files > 20 || affected_symbols > 50 {
910 ImpactLevel::Critical
911 } else if transitive_files > 10 || affected_symbols > 20 {
912 ImpactLevel::High
913 } else if transitive_files > 3 || affected_symbols > 5 {
914 ImpactLevel::Medium
915 } else {
916 ImpactLevel::Low
917 };
918
919 let breaking_changes = changed_symbols
923 .iter()
924 .filter(|s| s.kind == "function" || s.kind == "method")
925 .filter(|s| s.signature.is_some())
926 .filter(|s| {
928 s.signature
929 .as_ref()
930 .is_some_and(|sig| sig.starts_with("pub ") || sig.starts_with("export "))
931 })
932 .map(|s| format!("{} public API signature may have changed", s.name))
933 .collect();
934
935 let description = format!(
936 "Changed {} files affecting {} dependent files and {} symbols. {} tests may need updating.",
937 direct_files, transitive_files, affected_symbols, affected_tests
938 );
939
940 ImpactSummary {
941 level,
942 direct_files,
943 transitive_files,
944 affected_symbols,
945 affected_tests,
946 breaking_changes,
947 description,
948 }
949 }
950}
951
952impl PartialOrd for ContextDepth {
953 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
954 Some(self.cmp(other))
955 }
956}
957
958impl Ord for ContextDepth {
959 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
960 let self_num = match self {
961 ContextDepth::L1 => 1,
962 ContextDepth::L2 => 2,
963 ContextDepth::L3 => 3,
964 };
965 let other_num = match other {
966 ContextDepth::L1 => 1,
967 ContextDepth::L2 => 2,
968 ContextDepth::L3 => 3,
969 };
970 self_num.cmp(&other_num)
971 }
972}
973
974#[cfg(test)]
975mod tests {
976 use super::*;
977 use crate::index::types::{
978 FileEntry, FileId, IndexSymbolKind, Language, Span, SymbolId, Visibility,
979 };
980
981 fn create_test_index() -> (SymbolIndex, DepGraph) {
982 let mut index = SymbolIndex::new();
983 index.repo_name = "test".to_owned();
984
985 index.files.push(FileEntry {
987 id: FileId::new(0),
988 path: "src/main.rs".to_owned(),
989 language: Language::Rust,
990 content_hash: [0; 32],
991 symbols: 0..2,
992 imports: vec![],
993 lines: 100,
994 tokens: 500,
995 });
996 index.files.push(FileEntry {
997 id: FileId::new(1),
998 path: "src/lib.rs".to_owned(),
999 language: Language::Rust,
1000 content_hash: [0; 32],
1001 symbols: 2..3,
1002 imports: vec![],
1003 lines: 50,
1004 tokens: 250,
1005 });
1006 index.files.push(FileEntry {
1007 id: FileId::new(2),
1008 path: "tests/test_main.rs".to_owned(),
1009 language: Language::Rust,
1010 content_hash: [0; 32],
1011 symbols: 3..4,
1012 imports: vec![],
1013 lines: 30,
1014 tokens: 150,
1015 });
1016
1017 index.symbols.push(IndexSymbol {
1019 id: SymbolId::new(0),
1020 name: "main".to_owned(),
1021 kind: IndexSymbolKind::Function,
1022 file_id: FileId::new(0),
1023 span: Span::new(1, 0, 10, 0),
1024 signature: Some("fn main()".to_owned()),
1025 parent: None,
1026 visibility: Visibility::Public,
1027 docstring: None,
1028 });
1029 index.symbols.push(IndexSymbol {
1030 id: SymbolId::new(1),
1031 name: "helper".to_owned(),
1032 kind: IndexSymbolKind::Function,
1033 file_id: FileId::new(0),
1034 span: Span::new(15, 0, 25, 0),
1035 signature: Some("fn helper()".to_owned()),
1036 parent: None,
1037 visibility: Visibility::Private,
1038 docstring: None,
1039 });
1040 index.symbols.push(IndexSymbol {
1041 id: SymbolId::new(2),
1042 name: "lib_fn".to_owned(),
1043 kind: IndexSymbolKind::Function,
1044 file_id: FileId::new(1),
1045 span: Span::new(1, 0, 20, 0),
1046 signature: Some("pub fn lib_fn()".to_owned()),
1047 parent: None,
1048 visibility: Visibility::Public,
1049 docstring: None,
1050 });
1051 index.symbols.push(IndexSymbol {
1052 id: SymbolId::new(3),
1053 name: "test_main".to_owned(),
1054 kind: IndexSymbolKind::Function,
1055 file_id: FileId::new(2),
1056 span: Span::new(1, 0, 15, 0),
1057 signature: Some("fn test_main()".to_owned()),
1058 parent: None,
1059 visibility: Visibility::Private,
1060 docstring: None,
1061 });
1062
1063 index.rebuild_lookups();
1064
1065 let mut graph = DepGraph::new();
1067 graph.add_file_import(0, 1); graph.add_file_import(2, 0); graph.add_call(0, 2); (index, graph)
1072 }
1073
1074 #[test]
1075 fn test_context_expansion_l1() {
1076 let (index, graph) = create_test_index();
1077 let expander = ContextExpander::new(&index, &graph);
1078
1079 let changes = vec![DiffChange {
1080 file_path: "src/main.rs".to_owned(),
1081 old_path: None,
1082 line_ranges: vec![(5, 8)],
1083 change_type: ChangeType::Modified,
1084 diff_content: None,
1085 }];
1086
1087 let context = expander.expand(&changes, ContextDepth::L1, 10000);
1088
1089 assert_eq!(context.changed_files.len(), 1);
1090 assert_eq!(context.changed_symbols.len(), 1);
1091 assert_eq!(context.changed_symbols[0].name, "main");
1092 }
1093
1094 #[test]
1095 fn test_context_expansion_l2() {
1096 let (index, graph) = create_test_index();
1097 let expander = ContextExpander::new(&index, &graph);
1098
1099 let changes = vec![DiffChange {
1100 file_path: "src/lib.rs".to_owned(),
1101 old_path: None,
1102 line_ranges: vec![(1, 20)],
1103 change_type: ChangeType::Modified,
1104 diff_content: None,
1105 }];
1106
1107 let context = expander.expand(&changes, ContextDepth::L2, 10000);
1108
1109 assert!(!context.dependent_files.is_empty() || context.changed_files.len() == 1);
1111 }
1112
1113 #[test]
1114 fn test_test_file_detection() {
1115 let (index, graph) = create_test_index();
1116 let expander = ContextExpander::new(&index, &graph);
1117
1118 assert!(expander.is_test_file("tests/test_main.rs"));
1119 assert!(expander.is_test_file("src/foo.test.ts"));
1120 assert!(expander.is_test_file("spec/foo.spec.js"));
1121 assert!(!expander.is_test_file("src/main.rs"));
1122 }
1123
1124 #[test]
1125 fn test_impact_level() {
1126 assert_eq!(ImpactLevel::Low.name(), "low");
1127 assert_eq!(ImpactLevel::Critical.name(), "critical");
1128 }
1129
1130 #[test]
1131 fn test_change_classification_deleted() {
1132 let (index, graph) = create_test_index();
1133 let expander = ContextExpander::new(&index, &graph);
1134
1135 let change = DiffChange {
1136 file_path: "src/main.rs".to_owned(),
1137 old_path: None,
1138 line_ranges: vec![],
1139 change_type: ChangeType::Deleted,
1140 diff_content: None,
1141 };
1142
1143 let classification = expander.classify_change(&change, None);
1144 assert_eq!(classification, ChangeClassification::Deletion);
1145 }
1146
1147 #[test]
1148 fn test_change_classification_renamed() {
1149 let (index, graph) = create_test_index();
1150 let expander = ContextExpander::new(&index, &graph);
1151
1152 let change = DiffChange {
1153 file_path: "src/new_name.rs".to_owned(),
1154 old_path: Some("src/old_name.rs".to_owned()),
1155 line_ranges: vec![],
1156 change_type: ChangeType::Renamed,
1157 diff_content: None,
1158 };
1159
1160 let classification = expander.classify_change(&change, None);
1161 assert_eq!(classification, ChangeClassification::FileRename);
1162 }
1163
1164 #[test]
1165 fn test_change_classification_added() {
1166 let (index, graph) = create_test_index();
1167 let expander = ContextExpander::new(&index, &graph);
1168
1169 let change = DiffChange {
1170 file_path: "src/new_file.rs".to_owned(),
1171 old_path: None,
1172 line_ranges: vec![],
1173 change_type: ChangeType::Added,
1174 diff_content: None,
1175 };
1176
1177 let classification = expander.classify_change(&change, None);
1178 assert_eq!(classification, ChangeClassification::NewCode);
1179 }
1180
1181 #[test]
1182 fn test_change_classification_signature_change() {
1183 let (index, graph) = create_test_index();
1184 let expander = ContextExpander::new(&index, &graph);
1185
1186 let change = DiffChange {
1187 file_path: "src/main.rs".to_owned(),
1188 old_path: None,
1189 line_ranges: vec![(1, 10)],
1190 change_type: ChangeType::Modified,
1191 diff_content: Some("-fn helper(x: i32)\n+fn helper(x: i32, y: i32)".to_owned()),
1192 };
1193
1194 let classification = expander.classify_change(&change, None);
1195 assert_eq!(classification, ChangeClassification::SignatureChange);
1196 }
1197
1198 #[test]
1199 fn test_change_classification_type_definition() {
1200 let (index, graph) = create_test_index();
1201 let expander = ContextExpander::new(&index, &graph);
1202
1203 let change = DiffChange {
1204 file_path: "src/types.rs".to_owned(),
1205 old_path: None,
1206 line_ranges: vec![(1, 10)],
1207 change_type: ChangeType::Modified,
1208 diff_content: Some("+struct NewField {\n+ value: i32\n+}".to_owned()),
1209 };
1210
1211 let classification = expander.classify_change(&change, None);
1212 assert_eq!(classification, ChangeClassification::TypeDefinitionChange);
1213 }
1214
1215 #[test]
1216 fn test_change_classification_import_change() {
1217 let (index, graph) = create_test_index();
1218 let expander = ContextExpander::new(&index, &graph);
1219
1220 let change = DiffChange {
1221 file_path: "src/main.rs".to_owned(),
1222 old_path: None,
1223 line_ranges: vec![(1, 2)],
1224 change_type: ChangeType::Modified,
1225 diff_content: Some("+use std::collections::HashMap;".to_owned()),
1226 };
1227
1228 let classification = expander.classify_change(&change, None);
1229 assert_eq!(classification, ChangeClassification::ImportChange);
1230 }
1231
1232 #[test]
1233 fn test_change_classification_doc_only() {
1234 let (index, graph) = create_test_index();
1235 let expander = ContextExpander::new(&index, &graph);
1236
1237 let change = DiffChange {
1238 file_path: "src/main.rs".to_owned(),
1239 old_path: None,
1240 line_ranges: vec![(1, 3)],
1241 change_type: ChangeType::Modified,
1242 diff_content: Some("+/// This is a doc comment\n+/// Another doc line".to_owned()),
1243 };
1244
1245 let classification = expander.classify_change(&change, None);
1246 assert_eq!(classification, ChangeClassification::DocumentationOnly);
1247 }
1248
1249 #[test]
1250 fn test_classification_score_multipliers() {
1251 let (index, graph) = create_test_index();
1252 let expander = ContextExpander::new(&index, &graph);
1253
1254 let deletion_mult =
1256 expander.classification_score_multiplier(ChangeClassification::Deletion);
1257 let sig_mult =
1258 expander.classification_score_multiplier(ChangeClassification::SignatureChange);
1259 let impl_mult =
1260 expander.classification_score_multiplier(ChangeClassification::ImplementationChange);
1261 let doc_mult =
1262 expander.classification_score_multiplier(ChangeClassification::DocumentationOnly);
1263
1264 assert!(deletion_mult > sig_mult, "Deletion should have higher priority than signature");
1265 assert!(sig_mult > impl_mult, "Signature change should have higher priority than impl");
1266 assert!(impl_mult > doc_mult, "Implementation should have higher priority than docs");
1267 }
1268
1269 #[test]
1270 fn test_context_with_signature_change_includes_callers() {
1271 let (index, graph) = create_test_index();
1272 let expander = ContextExpander::new(&index, &graph);
1273
1274 let changes = vec![DiffChange {
1276 file_path: "src/lib.rs".to_owned(),
1277 old_path: None,
1278 line_ranges: vec![(1, 20)],
1279 change_type: ChangeType::Modified,
1280 diff_content: Some("-pub fn lib_fn()\n+pub fn lib_fn(new_param: i32)".to_owned()),
1281 }];
1282
1283 let context = expander.expand(&changes, ContextDepth::L2, 10000);
1284
1285 assert!(!context.changed_symbols.is_empty());
1287 if let Some(sym) = context.changed_symbols.first() {
1289 assert!(
1290 sym.relevance_reason.contains("signature")
1291 || sym.relevance_reason.contains("modified"),
1292 "Expected signature or modified in reason, got: {}",
1293 sym.relevance_reason
1294 );
1295 }
1296 }
1297}