Skip to main content

debtmap/comparison/
location_matcher.rs

1use anyhow::Result;
2
3use crate::priority::{UnifiedAnalysis, UnifiedDebtItem};
4
5/// Location pattern for matching debt items
6#[derive(Debug, Clone, PartialEq)]
7pub enum LocationPattern {
8    /// Exact match: file:function:line
9    Exact {
10        file: String,
11        function: String,
12        line: usize,
13    },
14    /// Function match: file:function (any line)
15    Function { file: String, function: String },
16    /// File match: file (all items in file)
17    File { file: String },
18    /// Line range match: file:*:line (any function at line)
19    LineRange { file: String, line: usize },
20}
21
22impl LocationPattern {
23    /// Parse location string into appropriate pattern
24    pub fn parse(location: &str) -> Result<Self> {
25        let parts: Vec<&str> = location.split(':').collect();
26        match parts.len() {
27            1 => Ok(Self::File {
28                file: parts[0].to_string(),
29            }),
30            2 => Ok(Self::Function {
31                file: parts[0].to_string(),
32                function: parts[1].to_string(),
33            }),
34            3 => {
35                if parts[1] == "*" {
36                    Ok(Self::LineRange {
37                        file: parts[0].to_string(),
38                        line: parts[2].parse()?,
39                    })
40                } else {
41                    Ok(Self::Exact {
42                        file: parts[0].to_string(),
43                        function: parts[1].to_string(),
44                        line: parts[2].parse()?,
45                    })
46                }
47            }
48            _ => Err(anyhow::anyhow!(
49                "Invalid location format: {}. Expected file[:function[:line]]",
50                location
51            )),
52        }
53    }
54}
55
56/// Matching strategy used to find items
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum MatchStrategy {
59    /// Exact file:function:line match
60    Exact,
61    /// Function-level match (file:function)
62    FunctionLevel,
63    /// Approximate name match (fuzzy)
64    ApproximateName,
65    /// File-level match (all items in file)
66    FileLevel,
67}
68
69impl MatchStrategy {
70    /// Get confidence score for this strategy
71    pub fn confidence(&self) -> f64 {
72        match self {
73            Self::Exact => 1.0,
74            Self::FunctionLevel => 0.8,
75            Self::ApproximateName => 0.6,
76            Self::FileLevel => 0.4,
77        }
78    }
79}
80
81/// Result of a location match operation
82#[derive(Debug, Clone)]
83pub struct MatchResult<'a> {
84    pub items: Vec<&'a UnifiedDebtItem>,
85    pub strategy: MatchStrategy,
86    pub confidence: f64,
87}
88
89/// Handles location matching with multiple strategies
90pub struct LocationMatcher;
91
92impl LocationMatcher {
93    pub fn new() -> Self {
94        Self
95    }
96
97    /// Find items matching the given location using cascading strategies
98    pub fn find_matches<'a>(
99        &self,
100        analysis: &'a UnifiedAnalysis,
101        location: &str,
102    ) -> Result<MatchResult<'a>> {
103        let pattern = LocationPattern::parse(location)?;
104
105        // Try strategies in order of specificity
106        if let Some(result) = self.try_exact_match(analysis, &pattern) {
107            return Ok(result);
108        }
109
110        if let Some(result) = self.try_function_match(analysis, &pattern) {
111            return Ok(result);
112        }
113
114        if let Some(result) = self.try_approximate_match(analysis, &pattern) {
115            return Ok(result);
116        }
117
118        if let Some(result) = self.try_file_match(analysis, &pattern) {
119            return Ok(result);
120        }
121
122        Err(anyhow::anyhow!(
123            "No items found matching location: {} (tried all strategies: exact, function-level, approximate, file-level)",
124            location
125        ))
126    }
127
128    /// Try exact match: file:function:line
129    fn try_exact_match<'a>(
130        &self,
131        analysis: &'a UnifiedAnalysis,
132        pattern: &LocationPattern,
133    ) -> Option<MatchResult<'a>> {
134        let (file, function, line) = match pattern {
135            LocationPattern::Exact {
136                file,
137                function,
138                line,
139            } => (file, function, *line),
140            _ => return None,
141        };
142
143        let item = analysis.items.iter().find(|item| {
144            let item_file = normalize_path(&item.location.file);
145            let target_file = normalize_path_str(file);
146
147            item_file == target_file
148                && item.location.function == *function
149                && item.location.line == line
150        })?;
151
152        Some(MatchResult {
153            items: vec![item],
154            strategy: MatchStrategy::Exact,
155            confidence: MatchStrategy::Exact.confidence(),
156        })
157    }
158
159    /// Try function-level match: file:function (any line)
160    fn try_function_match<'a>(
161        &self,
162        analysis: &'a UnifiedAnalysis,
163        pattern: &LocationPattern,
164    ) -> Option<MatchResult<'a>> {
165        let (file, function) = match pattern {
166            LocationPattern::Exact { file, function, .. }
167            | LocationPattern::Function { file, function } => (file, function),
168            _ => return None,
169        };
170
171        let items: Vec<&UnifiedDebtItem> = analysis
172            .items
173            .iter()
174            .filter(|item| {
175                let item_file = normalize_path(&item.location.file);
176                let target_file = normalize_path_str(file);
177
178                item_file == target_file && item.location.function == *function
179            })
180            .collect();
181
182        if items.is_empty() {
183            None
184        } else {
185            Some(MatchResult {
186                items,
187                strategy: MatchStrategy::FunctionLevel,
188                confidence: MatchStrategy::FunctionLevel.confidence(),
189            })
190        }
191    }
192
193    /// Try approximate name match: fuzzy match on function/struct names
194    fn try_approximate_match<'a>(
195        &self,
196        analysis: &'a UnifiedAnalysis,
197        pattern: &LocationPattern,
198    ) -> Option<MatchResult<'a>> {
199        let (file, target_name) = match pattern {
200            LocationPattern::Exact { file, function, .. }
201            | LocationPattern::Function { file, function } => (file, function),
202            _ => return None,
203        };
204
205        let target_file = normalize_path_str(file);
206
207        // Find items in the same file with similar names
208        let candidates: Vec<(&UnifiedDebtItem, f64)> = analysis
209            .items
210            .iter()
211            .filter(|item| normalize_path(&item.location.file) == target_file)
212            .filter_map(|item| {
213                let similarity = calculate_similarity(target_name, &item.location.function);
214                if similarity >= 0.5 {
215                    Some((item, similarity))
216                } else {
217                    None
218                }
219            })
220            .collect();
221
222        if candidates.is_empty() {
223            return None;
224        }
225
226        // Find the best match(es) - take all items with the highest similarity score
227        let max_similarity = candidates
228            .iter()
229            .map(|(_, sim)| *sim)
230            .fold(0.0f64, |a, b| a.max(b));
231
232        let items: Vec<&UnifiedDebtItem> = candidates
233            .iter()
234            .filter(|(_, sim)| *sim == max_similarity)
235            .map(|(item, _)| *item)
236            .collect();
237
238        Some(MatchResult {
239            items,
240            strategy: MatchStrategy::ApproximateName,
241            confidence: max_similarity * MatchStrategy::ApproximateName.confidence(),
242        })
243    }
244
245    /// Try file-level match: all items in the file
246    fn try_file_match<'a>(
247        &self,
248        analysis: &'a UnifiedAnalysis,
249        pattern: &LocationPattern,
250    ) -> Option<MatchResult<'a>> {
251        let file = match pattern {
252            LocationPattern::File { file } => file,
253            LocationPattern::Exact { file, .. }
254            | LocationPattern::Function { file, .. }
255            | LocationPattern::LineRange { file, .. } => file,
256        };
257
258        let target_file = normalize_path_str(file);
259
260        let items: Vec<&UnifiedDebtItem> = analysis
261            .items
262            .iter()
263            .filter(|item| normalize_path(&item.location.file) == target_file)
264            .collect();
265
266        if items.is_empty() {
267            None
268        } else {
269            // Confidence decreases with more items (less specific)
270            let confidence =
271                MatchStrategy::FileLevel.confidence() * (1.0 / (items.len() as f64).sqrt());
272            Some(MatchResult {
273                items,
274                strategy: MatchStrategy::FileLevel,
275                confidence: confidence.max(0.3), // Minimum confidence
276            })
277        }
278    }
279}
280
281impl Default for LocationMatcher {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287/// Normalize a path for comparison
288fn normalize_path(path: &std::path::Path) -> String {
289    let path_str = path.to_string_lossy();
290    normalize_path_str(&path_str)
291}
292
293/// Normalize a path string for comparison
294fn normalize_path_str(path: &str) -> String {
295    path.strip_prefix("./").unwrap_or(path).to_string()
296}
297
298/// Calculate similarity between two strings (simple prefix-based)
299fn calculate_similarity(a: &str, b: &str) -> f64 {
300    if a == b {
301        return 1.0;
302    }
303
304    let a_lower = a.to_lowercase();
305    let b_lower = b.to_lowercase();
306
307    // Check if one is a prefix of the other
308    if a_lower.starts_with(&b_lower) || b_lower.starts_with(&a_lower) {
309        let shorter = a_lower.len().min(b_lower.len());
310        let longer = a_lower.len().max(b_lower.len());
311        return shorter as f64 / longer as f64;
312    }
313
314    // Check if they share a common prefix
315    let common_prefix_len = a_lower
316        .chars()
317        .zip(b_lower.chars())
318        .take_while(|(ca, cb)| ca == cb)
319        .count();
320
321    let max_len = a_lower.len().max(b_lower.len());
322    if max_len == 0 {
323        return 0.0;
324    }
325
326    // Simple similarity based on common prefix length
327    common_prefix_len as f64 / max_len as f64
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    use crate::priority::{
335        DebtType, FunctionRole, ImpactMetrics,
336        unified_scorer::{Location, UnifiedScore},
337    };
338    use im::Vector;
339    use std::path::PathBuf;
340
341    fn create_test_item(file: &str, function: &str, line: usize) -> UnifiedDebtItem {
342        UnifiedDebtItem {
343            location: Location {
344                file: PathBuf::from(file),
345                function: function.to_string(),
346                line,
347            },
348            debt_type: DebtType::TestingGap {
349                coverage: 0.0,
350                cyclomatic: 10,
351                cognitive: 20,
352            },
353            unified_score: UnifiedScore {
354                complexity_factor: 5.0,
355                coverage_factor: 0.0,
356                dependency_factor: 0.0,
357                role_multiplier: 1.0,
358                final_score: 50.0,
359                base_score: None,
360                exponential_factor: None,
361                risk_boost: None,
362                pre_adjustment_score: None,
363                adjustment_applied: None,
364                purity_factor: None,
365                refactorability_factor: None,
366                pattern_factor: None,
367                // Spec 260: Score transparency fields
368                debt_adjustment: None,
369                pre_normalization_score: None,
370                structural_multiplier: Some(1.0),
371                has_coverage_data: false,
372                contextual_risk_multiplier: None,
373                pre_contextual_score: None,
374                debt_type_multiplier: None,
375            },
376            function_role: FunctionRole::PureLogic,
377            recommendation: crate::priority::ActionableRecommendation {
378                primary_action: "Test".to_string(),
379                rationale: "Test".to_string(),
380                implementation_steps: vec![],
381                related_items: vec![],
382                steps: None,
383                estimated_effort_hours: None,
384            },
385            expected_impact: ImpactMetrics {
386                coverage_improvement: 0.0,
387                lines_reduction: 0,
388                complexity_reduction: 0.0,
389                risk_reduction: 0.0,
390            },
391            transitive_coverage: None,
392            file_context: None,
393            upstream_dependencies: 0,
394            downstream_dependencies: 0,
395            upstream_callers: vec![],
396            downstream_callees: vec![],
397            upstream_production_callers: vec![],
398            upstream_test_callers: vec![],
399            production_blast_radius: 0,
400            nesting_depth: 2,
401            function_length: 50,
402            cyclomatic_complexity: 10,
403            cognitive_complexity: 20,
404            is_pure: None,
405            purity_confidence: None,
406            purity_level: None,
407            god_object_indicators: None,
408            tier: None,
409            function_context: None,
410            context_confidence: None,
411            contextual_recommendation: None,
412            pattern_analysis: None,
413            context_multiplier: None,
414            context_type: None,
415            language_specific: None, // spec 190
416            detected_pattern: None,
417            contextual_risk: None, // spec 203
418            file_line_count: None,
419            responsibility_category: None,
420            error_swallowing_count: None,
421            error_swallowing_patterns: None,
422            entropy_analysis: None,
423            context_suggestion: None,
424        }
425    }
426
427    fn create_test_analysis(items: Vec<UnifiedDebtItem>) -> UnifiedAnalysis {
428        UnifiedAnalysis {
429            items: Vector::from(items),
430            file_items: Vector::new(),
431            total_impact: ImpactMetrics {
432                coverage_improvement: 0.0,
433                lines_reduction: 0,
434                complexity_reduction: 0.0,
435                risk_reduction: 0.0,
436            },
437            total_debt_score: 50.0,
438            debt_density: 0.0,
439            total_lines_of_code: 1000,
440            call_graph: crate::priority::CallGraph::new(),
441            data_flow_graph: crate::data_flow::DataFlowGraph::new(),
442            overall_coverage: None,
443            has_coverage_data: false,
444            timings: None,
445            stats: crate::priority::FilterStatistics::new(),
446            analyzed_files: std::collections::HashMap::new(),
447        }
448    }
449
450    #[test]
451    fn test_parse_exact_location() {
452        let pattern = LocationPattern::parse("src/main.rs:func:42").unwrap();
453        assert_eq!(
454            pattern,
455            LocationPattern::Exact {
456                file: "src/main.rs".to_string(),
457                function: "func".to_string(),
458                line: 42
459            }
460        );
461    }
462
463    #[test]
464    fn test_parse_function_location() {
465        let pattern = LocationPattern::parse("src/main.rs:func").unwrap();
466        assert_eq!(
467            pattern,
468            LocationPattern::Function {
469                file: "src/main.rs".to_string(),
470                function: "func".to_string(),
471            }
472        );
473    }
474
475    #[test]
476    fn test_parse_file_location() {
477        let pattern = LocationPattern::parse("src/main.rs").unwrap();
478        assert_eq!(
479            pattern,
480            LocationPattern::File {
481                file: "src/main.rs".to_string()
482            }
483        );
484    }
485
486    #[test]
487    fn test_parse_line_range() {
488        let pattern = LocationPattern::parse("src/main.rs:*:42").unwrap();
489        assert_eq!(
490            pattern,
491            LocationPattern::LineRange {
492                file: "src/main.rs".to_string(),
493                line: 42
494            }
495        );
496    }
497
498    #[test]
499    fn test_exact_match() {
500        let analysis = create_test_analysis(vec![create_test_item("src/main.rs", "func", 42)]);
501        let matcher = LocationMatcher::new();
502
503        let result = matcher
504            .find_matches(&analysis, "src/main.rs:func:42")
505            .unwrap();
506        assert_eq!(result.strategy, MatchStrategy::Exact);
507        assert_eq!(result.items.len(), 1);
508        assert_eq!(result.confidence, 1.0);
509    }
510
511    #[test]
512    fn test_exact_match_with_path_normalization() {
513        let analysis = create_test_analysis(vec![create_test_item("./src/main.rs", "func", 42)]);
514        let matcher = LocationMatcher::new();
515
516        let result = matcher
517            .find_matches(&analysis, "src/main.rs:func:42")
518            .unwrap();
519        assert_eq!(result.strategy, MatchStrategy::Exact);
520        assert_eq!(result.items.len(), 1);
521    }
522
523    #[test]
524    fn test_function_level_match() {
525        let analysis = create_test_analysis(vec![
526            create_test_item("src/main.rs", "func", 42),
527            create_test_item("src/main.rs", "func", 50),
528        ]);
529        let matcher = LocationMatcher::new();
530
531        // Try exact match with wrong line, should fall back to function-level
532        let result = matcher
533            .find_matches(&analysis, "src/main.rs:func:99")
534            .unwrap();
535        assert_eq!(result.strategy, MatchStrategy::FunctionLevel);
536        assert_eq!(result.items.len(), 2);
537        assert_eq!(result.confidence, 0.8);
538    }
539
540    #[test]
541    fn test_approximate_match() {
542        let analysis = create_test_analysis(vec![
543            create_test_item("src/main.rs", "EnhancedMarkdownWriter", 10),
544            create_test_item("src/main.rs", "other_func", 20),
545        ]);
546        let matcher = LocationMatcher::new();
547
548        // Try to match with similar name
549        let result = matcher
550            .find_matches(&analysis, "src/main.rs:EnhancedMarkdown:1")
551            .unwrap();
552        assert_eq!(result.strategy, MatchStrategy::ApproximateName);
553        assert_eq!(result.items.len(), 1);
554        // Confidence is similarity * strategy base confidence
555        // "EnhancedMarkdown" vs "EnhancedMarkdownWriter" = 16/23 * 0.6 ≈ 0.42
556        assert!(result.confidence > 0.3);
557    }
558
559    #[test]
560    fn test_file_level_match() {
561        let analysis = create_test_analysis(vec![
562            create_test_item("src/main.rs", "func1", 10),
563            create_test_item("src/main.rs", "func2", 20),
564            create_test_item("src/other.rs", "func3", 30),
565        ]);
566        let matcher = LocationMatcher::new();
567
568        // Try to match non-existent function, should fall back to file-level
569        let result = matcher
570            .find_matches(&analysis, "src/main.rs:nonexistent:1")
571            .unwrap();
572        assert_eq!(result.strategy, MatchStrategy::FileLevel);
573        assert_eq!(result.items.len(), 2);
574        assert!(result.confidence >= 0.3);
575    }
576
577    #[test]
578    fn test_no_match() {
579        let analysis = create_test_analysis(vec![create_test_item("src/main.rs", "func", 42)]);
580        let matcher = LocationMatcher::new();
581
582        let result = matcher.find_matches(&analysis, "src/other.rs:func:42");
583        assert!(result.is_err());
584    }
585
586    #[test]
587    fn test_similarity_exact() {
588        assert_eq!(calculate_similarity("func", "func"), 1.0);
589    }
590
591    #[test]
592    fn test_similarity_prefix() {
593        let sim = calculate_similarity("EnhancedMarkdownWriter", "EnhancedMarkdown");
594        assert!(sim > 0.5);
595        assert!(sim < 1.0);
596    }
597
598    #[test]
599    fn test_similarity_different() {
600        let sim = calculate_similarity("func1", "other");
601        assert!(sim < 0.5);
602    }
603}