infiniloom_engine/index/
context.rs

1//! Context expander for diff-based queries.
2//!
3//! Given a diff (changed files and lines), this module expands the context
4//! to include relevant dependent files, symbols, and call graphs.
5
6use super::types::{DepGraph, FileEntry, IndexSymbol, IndexSymbolKind, SymbolIndex};
7use std::collections::{HashSet, VecDeque};
8
9/// Context expansion depth levels
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ContextDepth {
12    /// L1: Containing functions only
13    L1,
14    /// L2: Direct dependents (default)
15    #[default]
16    L2,
17    /// L3: Transitive dependents
18    L3,
19}
20
21/// A change in a diff
22#[derive(Debug, Clone)]
23pub struct DiffChange {
24    /// File path (for renames, this is the NEW path)
25    pub file_path: String,
26    /// Old file path (only set for renames)
27    pub old_path: Option<String>,
28    /// Changed line ranges (start, end)
29    pub line_ranges: Vec<(u32, u32)>,
30    /// Type of change
31    pub change_type: ChangeType,
32    /// Raw diff content (the actual +/- lines), if requested
33    pub diff_content: Option<String>,
34}
35
36/// Type of change
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ChangeType {
39    Added,
40    Modified,
41    Deleted,
42    Renamed,
43}
44
45/// More detailed change classification for smart expansion
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum ChangeClassification {
48    /// New code added - include dependents at normal priority
49    NewCode,
50    /// Function signature changed - include ALL callers at high priority
51    SignatureChange,
52    /// Type/struct/class definition changed - include all usages
53    TypeDefinitionChange,
54    /// Function body only changed - lower impact, only direct dependents
55    ImplementationChange,
56    /// Code deleted - callers will break, highest priority
57    Deletion,
58    /// File renamed - importers need updating
59    FileRename,
60    /// Import/dependency changed - may affect resolution
61    ImportChange,
62    /// Documentation/comment only - minimal impact
63    DocumentationOnly,
64}
65
66/// Expanded context result
67#[derive(Debug, Clone)]
68pub struct ExpandedContext {
69    /// Changed symbols (directly modified)
70    pub changed_symbols: Vec<ContextSymbol>,
71    /// Changed files
72    pub changed_files: Vec<ContextFile>,
73    /// Dependent symbols (affected by changes)
74    pub dependent_symbols: Vec<ContextSymbol>,
75    /// Dependent files
76    pub dependent_files: Vec<ContextFile>,
77    /// Related tests
78    pub related_tests: Vec<ContextFile>,
79    /// Call chains involving changed symbols
80    pub call_chains: Vec<CallChain>,
81    /// Summary of impact
82    pub impact_summary: ImpactSummary,
83    /// Total estimated tokens
84    pub total_tokens: u32,
85}
86
87/// A symbol in the context
88#[derive(Debug, Clone)]
89pub struct ContextSymbol {
90    /// Symbol ID
91    pub id: u32,
92    /// Symbol name
93    pub name: String,
94    /// Symbol kind (function, class, etc.)
95    pub kind: String,
96    /// File path
97    pub file_path: String,
98    /// Start line
99    pub start_line: u32,
100    /// End line
101    pub end_line: u32,
102    /// Signature
103    pub signature: Option<String>,
104    /// Why this symbol is relevant
105    pub relevance_reason: String,
106    /// Relevance score (0.0 - 1.0)
107    pub relevance_score: f32,
108}
109
110/// A file in the context
111#[derive(Debug, Clone)]
112pub struct ContextFile {
113    /// File ID
114    pub id: u32,
115    /// File path
116    pub path: String,
117    /// Language
118    pub language: String,
119    /// Why this file is relevant
120    pub relevance_reason: String,
121    /// Relevance score (0.0 - 1.0)
122    pub relevance_score: f32,
123    /// Estimated tokens
124    pub tokens: u32,
125    /// Relevant sections (line ranges)
126    pub relevant_sections: Vec<(u32, u32)>,
127    /// Raw diff content (the actual +/- lines), if available
128    pub diff_content: Option<String>,
129    /// Extracted snippets for LLM context
130    pub snippets: Vec<ContextSnippet>,
131}
132
133/// A snippet of source context for a file
134#[derive(Debug, Clone)]
135pub struct ContextSnippet {
136    /// Start line (1-indexed)
137    pub start_line: u32,
138    /// End line (1-indexed)
139    pub end_line: u32,
140    /// Why this snippet was included
141    pub reason: String,
142    /// Snippet content
143    pub content: String,
144}
145
146/// A call chain for understanding impact
147#[derive(Debug, Clone)]
148pub struct CallChain {
149    /// Symbols in the chain (from caller to callee)
150    pub symbols: Vec<String>,
151    /// Files involved
152    pub files: Vec<String>,
153}
154
155/// Summary of the impact
156#[derive(Debug, Clone, Default)]
157pub struct ImpactSummary {
158    /// Impact level (low, medium, high, critical)
159    pub level: ImpactLevel,
160    /// Number of directly affected files
161    pub direct_files: usize,
162    /// Number of transitively affected files
163    pub transitive_files: usize,
164    /// Number of affected symbols
165    pub affected_symbols: usize,
166    /// Number of affected tests
167    pub affected_tests: usize,
168    /// Breaking changes detected
169    pub breaking_changes: Vec<String>,
170    /// Description of the impact
171    pub description: String,
172}
173
174/// Impact severity level
175#[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
195/// Context expander
196pub struct ContextExpander<'a> {
197    index: &'a SymbolIndex,
198    graph: &'a DepGraph,
199}
200
201impl<'a> ContextExpander<'a> {
202    /// Create a new context expander
203    pub fn new(index: &'a SymbolIndex, graph: &'a DepGraph) -> Self {
204        Self { index, graph }
205    }
206
207    /// Classify a change for smart expansion
208    ///
209    /// Analyzes the diff content and symbol kinds to determine what type of change occurred,
210    /// which affects how aggressively we expand context.
211    pub fn classify_change(
212        &self,
213        change: &DiffChange,
214        symbol: Option<&IndexSymbol>,
215    ) -> ChangeClassification {
216        // File-level classifications
217        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        // Analyze diff content if available for more detailed classification
228        if let Some(diff) = &change.diff_content {
229            // Check for signature changes (function/method definition lines changed)
230            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                // Check if it's a type definition
253                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            // Check for import changes
265            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            // Check for documentation-only changes
274            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) // Skip empty diff markers
278                .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        // Symbol-based classification
288        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                    // If we can't determine more, assume implementation change
300                    return ChangeClassification::ImplementationChange;
301                },
302                _ => {},
303            }
304        }
305
306        // Default to implementation change (safest assumption for modified code)
307        ChangeClassification::ImplementationChange
308    }
309
310    /// Get relevance score multiplier based on change classification
311    fn classification_score_multiplier(&self, classification: ChangeClassification) -> f32 {
312        match classification {
313            ChangeClassification::Deletion => 1.5, // Highest priority - callers will break
314            ChangeClassification::SignatureChange => 1.3, // High priority - callers may need updates
315            ChangeClassification::TypeDefinitionChange => 1.2, // High priority - usages may break
316            ChangeClassification::FileRename => 1.1,      // Medium-high - importers need updates
317            ChangeClassification::ImportChange => 0.9,    // Medium - may affect resolution
318            ChangeClassification::NewCode => 0.8,         // Normal priority
319            ChangeClassification::ImplementationChange => 0.7, // Lower priority - internal change
320            ChangeClassification::DocumentationOnly => 0.3, // Minimal impact
321        }
322    }
323
324    /// Get caller count for a symbol (for importance weighting)
325    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    /// Expand context for a diff
330    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(); // Symbols needing extra caller expansion
347
348        // Phase 1: Map changes to symbols with classification
349        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            // Find symbols containing changed lines
371            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                            // Classify this change for smart expansion
378                            let classification = self.classify_change(change, Some(symbol));
379                            change_classifications.push(classification);
380
381                            // Calculate relevance score based on:
382                            // 1. Base score of 1.0 (directly modified)
383                            // 2. Classification multiplier
384                            // 3. Caller count bonus (more callers = higher impact)
385                            let caller_count = self.get_caller_count(symbol.id.as_u32());
386                            let caller_bonus = (caller_count as f32 * 0.05).min(0.3); // Max 0.3 bonus
387                            let base_score = 1.0 + caller_bonus;
388
389                            // Mark high-impact symbols for extra expansion
390                            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            // Classify file-level change
426            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        // Determine overall change impact for expansion decisions
443        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        // Phase 2: Expand to dependents based on depth (with smart expansion)
453        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                        // Higher score for high-impact changes
460                        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            // Expand symbols - with extra expansion for high-impact symbols
482            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                            // Determine if this is a caller of a high-impact symbol
489                            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            // For high-impact changes, also include ALL callers (not just direct refs)
510            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, // High priority
525                                        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        // Phase 3: Find related tests (via imports AND naming conventions)
558        let mut seen_test_ids: HashSet<u32> = HashSet::new();
559
560        // 3a: Find tests via import analysis
561        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        // 3b: Find tests via naming conventions
586        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        // Phase 4: Build call chains for changed symbols
609        for sym in &changed_symbols {
610            let chains = self.build_call_chains(sym.id, 3);
611            call_chains.extend(chains);
612        }
613
614        // Phase 5: Compute impact summary
615        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        // Phase 6: Select within token budget
624        // Sort by relevance and truncate if needed
625        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        // Truncate to budget - apply to all file collections
642        let mut running_tokens = changed_files.iter().map(|f| f.tokens).sum::<u32>();
643
644        // Truncate dependent files first (lower priority than changed files)
645        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        // Truncate related tests (lower priority than dependent files)
655        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    /// Expand to L2 (direct dependents)
677    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    /// Expand to L3 (transitive dependents)
686    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    /// Expand symbol references
713    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    /// Check if a file is a test file
723    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    /// Find tests related to a source file by naming convention.
738    ///
739    /// Looks for common test file naming patterns like:
740    /// - `foo.rs` -> `foo_test.rs`, `test_foo.rs`, `tests/foo.rs`
741    /// - `src/foo.py` -> `tests/test_foo.py`, `src/foo_test.py`
742    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        // Common test file patterns
756        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    /// Convert symbol to context symbol
782    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    /// Build call chains for a symbol
804    fn build_call_chains(&self, symbol_id: u32, max_depth: usize) -> Vec<CallChain> {
805        let mut chains = Vec::new();
806
807        // Build upstream chain (callers)
808        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        // Build downstream chain (callees)
822        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    /// Compute impact summary
895    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        // Determine impact level
909        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        // Detect potential breaking changes
920        // Only flag public/exported functions and methods - private internals aren't API
921        // Note: We can't determine if signature actually changed (no old version), so flag as "potentially"
922        let breaking_changes = changed_symbols
923            .iter()
924            .filter(|s| s.kind == "function" || s.kind == "method")
925            .filter(|s| s.signature.is_some())
926            // Only flag symbols that start with "pub" in their signature (public API)
927            .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        // Add files
986        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        // Add symbols
1018        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        // Create graph
1066        let mut graph = DepGraph::new();
1067        graph.add_file_import(0, 1); // main imports lib
1068        graph.add_file_import(2, 0); // test imports main
1069        graph.add_call(0, 2); // main calls lib_fn
1070
1071        (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        // Should find main.rs as dependent (it imports lib.rs)
1110        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        // Deletion has highest multiplier
1255        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        // Modify lib_fn which is called by main
1275        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        // Should detect as signature change and expand more aggressively
1286        assert!(!context.changed_symbols.is_empty());
1287        // Changed symbol should have signature change in reason
1288        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}