features_cli/
coverage_parser.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::models::Feature;
7
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct FileCoverageStats {
10    pub lines_total: usize,
11    pub lines_covered: usize,
12    pub lines_missed: usize,
13    pub line_coverage_percent: f64,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub branches_total: Option<usize>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub branches_covered: Option<usize>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub branch_coverage_percent: Option<f64>,
20}
21
22impl Default for FileCoverageStats {
23    fn default() -> Self {
24        Self {
25            lines_total: 0,
26            lines_covered: 0,
27            lines_missed: 0,
28            line_coverage_percent: 0.0,
29            branches_total: None,
30            branches_covered: None,
31            branch_coverage_percent: None,
32        }
33    }
34}
35
36impl FileCoverageStats {
37    pub fn new() -> Self {
38        Self {
39            lines_total: 0,
40            lines_covered: 0,
41            lines_missed: 0,
42            line_coverage_percent: 0.0,
43            branches_total: None,
44            branches_covered: None,
45            branch_coverage_percent: None,
46        }
47    }
48
49    pub fn calculate_percentages(&mut self) {
50        if self.lines_total > 0 {
51            self.line_coverage_percent =
52                (self.lines_covered as f64 / self.lines_total as f64) * 100.0;
53        }
54
55        if let (Some(total), Some(covered)) = (self.branches_total, self.branches_covered)
56            && total > 0
57        {
58            self.branch_coverage_percent = Some((covered as f64 / total as f64) * 100.0);
59        }
60    }
61}
62
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct CoverageStats {
65    pub lines_total: usize,
66    pub lines_covered: usize,
67    pub lines_missed: usize,
68    pub line_coverage_percent: f64,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub branches_total: Option<usize>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub branches_covered: Option<usize>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub branch_coverage_percent: Option<f64>,
75    #[serde(skip_serializing_if = "HashMap::is_empty")]
76    pub files: HashMap<String, FileCoverageStats>,
77}
78
79impl Default for CoverageStats {
80    fn default() -> Self {
81        Self {
82            lines_total: 0,
83            lines_covered: 0,
84            lines_missed: 0,
85            line_coverage_percent: 0.0,
86            branches_total: None,
87            branches_covered: None,
88            branch_coverage_percent: None,
89            files: HashMap::new(),
90        }
91    }
92}
93
94impl CoverageStats {
95    // used by tests
96    #[allow(dead_code)]
97    pub fn new() -> Self {
98        Self {
99            lines_total: 0,
100            lines_covered: 0,
101            lines_missed: 0,
102            line_coverage_percent: 0.0,
103            branches_total: None,
104            branches_covered: None,
105            branch_coverage_percent: None,
106            files: HashMap::new(),
107        }
108    }
109
110    pub fn calculate_percentages(&mut self) {
111        if self.lines_total > 0 {
112            self.line_coverage_percent =
113                (self.lines_covered as f64 / self.lines_total as f64) * 100.0;
114        }
115
116        if let (Some(total), Some(covered)) = (self.branches_total, self.branches_covered)
117            && total > 0
118        {
119            self.branch_coverage_percent = Some((covered as f64 / total as f64) * 100.0);
120        }
121    }
122
123    pub fn merge(&mut self, other: &CoverageStats) {
124        self.lines_total += other.lines_total;
125        self.lines_covered += other.lines_covered;
126        // Recalculate lines_missed based on merged totals
127        self.lines_missed = self.lines_total.saturating_sub(self.lines_covered);
128
129        if let Some(other_branches_total) = other.branches_total {
130            self.branches_total = Some(self.branches_total.unwrap_or(0) + other_branches_total);
131        }
132
133        if let Some(other_branches_covered) = other.branches_covered {
134            self.branches_covered =
135                Some(self.branches_covered.unwrap_or(0) + other_branches_covered);
136        }
137
138        // Merge file-level coverage
139        for (file_path, file_stats) in &other.files {
140            self.files.insert(file_path.clone(), file_stats.clone());
141        }
142
143        self.calculate_percentages();
144    }
145}
146
147#[derive(Debug)]
148struct FileCoverage {
149    path: PathBuf,
150    lines_total: usize,
151    lines_covered: usize,
152    branches_total: usize,
153    branches_covered: usize,
154}
155
156/// Detects and parses coverage reports from the .coverage directory
157pub fn parse_coverage_reports(
158    coverage_dir: &Path,
159    base_path: &Path,
160) -> Result<HashMap<String, CoverageStats>> {
161    let mut coverage_map: HashMap<String, CoverageStats> = HashMap::new();
162
163    if !coverage_dir.exists() {
164        return Ok(coverage_map);
165    }
166
167    // Find the project root (common path between coverage_dir and base_path)
168    let project_root =
169        find_common_ancestor(coverage_dir, base_path).unwrap_or_else(|| base_path.to_path_buf());
170
171    // Find all coverage files in the directory
172    let entries = fs::read_dir(coverage_dir).context("Failed to read coverage directory")?;
173
174    for entry in entries {
175        let entry = entry?;
176        let path = entry.path();
177
178        if path.is_file() {
179            let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
180
181            // Detect file type and parse accordingly
182            if (file_name.ends_with(".xml") || file_name.contains("cobertura"))
183                && let Ok(file_coverage) = parse_cobertura_xml(&path)
184            {
185                merge_file_coverage(&mut coverage_map, file_coverage, &project_root, base_path);
186            } else if (file_name.ends_with(".info") || file_name.contains("lcov"))
187                && let Ok(file_coverage) = parse_lcov(&path)
188            {
189                merge_file_coverage(&mut coverage_map, file_coverage, &project_root, base_path);
190            }
191        }
192    }
193
194    Ok(coverage_map)
195}
196
197/// Find the common ancestor path between two paths
198fn find_common_ancestor(path1: &Path, path2: &Path) -> Option<PathBuf> {
199    // Try to canonicalize both paths
200    let canon1 = std::fs::canonicalize(path1).ok()?;
201    let canon2 = std::fs::canonicalize(path2).ok()?;
202
203    let mut common = PathBuf::new();
204    let components1: Vec<_> = canon1.components().collect();
205    let components2: Vec<_> = canon2.components().collect();
206
207    for (c1, c2) in components1.iter().zip(components2.iter()) {
208        if c1 == c2 {
209            common.push(c1);
210        } else {
211            break;
212        }
213    }
214
215    if common.as_os_str().is_empty() {
216        None
217    } else {
218        Some(common)
219    }
220}
221
222/// Merge file coverage data into the coverage map
223fn merge_file_coverage(
224    coverage_map: &mut HashMap<String, CoverageStats>,
225    file_coverage: Vec<FileCoverage>,
226    project_root: &Path,
227    base_path: &Path,
228) {
229    for fc in file_coverage {
230        // Normalize the path: try to make it relative to base_path
231        let normalized_path = normalize_coverage_path(&fc.path, project_root, base_path);
232
233        let stats = coverage_map.entry(normalized_path.clone()).or_default();
234
235        // Create individual file stats
236        let mut file_stats = FileCoverageStats::new();
237        file_stats.lines_total = fc.lines_total;
238        file_stats.lines_covered = fc.lines_covered;
239        file_stats.lines_missed = fc.lines_total.saturating_sub(fc.lines_covered);
240
241        if fc.branches_total > 0 {
242            file_stats.branches_total = Some(fc.branches_total);
243            file_stats.branches_covered = Some(fc.branches_covered);
244        }
245
246        file_stats.calculate_percentages();
247
248        // Store file-level coverage
249        stats.files.insert(normalized_path, file_stats.clone());
250
251        // Update aggregate stats
252        stats.lines_total += fc.lines_total;
253        stats.lines_covered += fc.lines_covered;
254        stats.lines_missed += fc.lines_total.saturating_sub(fc.lines_covered);
255
256        if fc.branches_total > 0 {
257            stats.branches_total = Some(stats.branches_total.unwrap_or(0) + fc.branches_total);
258            stats.branches_covered =
259                Some(stats.branches_covered.unwrap_or(0) + fc.branches_covered);
260        }
261
262        stats.calculate_percentages();
263    }
264}
265
266/// Normalize a coverage file path to be relative to base_path
267fn normalize_coverage_path(file_path: &Path, project_root: &Path, base_path: &Path) -> String {
268    // First, try to resolve the file path relative to project_root
269    let resolved_path = if file_path.is_absolute() {
270        file_path.to_path_buf()
271    } else {
272        project_root.join(file_path)
273    };
274
275    // Try to canonicalize paths for accurate comparison
276    let canon_resolved = std::fs::canonicalize(&resolved_path).unwrap_or(resolved_path.clone());
277    let canon_base = std::fs::canonicalize(base_path).unwrap_or_else(|_| base_path.to_path_buf());
278
279    // Make the path relative to base_path
280    if let Ok(relative) = canon_resolved.strip_prefix(&canon_base) {
281        relative.to_string_lossy().to_string()
282    } else {
283        // If we can't make it relative, try with the original paths
284        if let Ok(relative) = resolved_path.strip_prefix(base_path) {
285            relative.to_string_lossy().to_string()
286        } else {
287            // Last resort: use the original file path as string
288            file_path.to_string_lossy().to_string()
289        }
290    }
291}
292
293/// Parse Cobertura XML format
294fn parse_cobertura_xml(path: &Path) -> Result<Vec<FileCoverage>> {
295    let content = fs::read_to_string(path).context("Failed to read Cobertura XML file")?;
296
297    let mut file_coverage = Vec::new();
298
299    // Simple XML parsing without external dependencies
300    // This is a basic parser that looks for <class> elements with coverage attributes
301    let lines: Vec<&str> = content.lines().collect();
302    let mut current_file: Option<String> = None;
303    let mut lines_total = 0;
304    let mut lines_covered = 0;
305    let mut branches_total = 0;
306    let mut branches_covered = 0;
307
308    for line in lines {
309        let trimmed = line.trim();
310
311        // Look for class or file elements with filename attribute
312        if trimmed.contains("<class") || trimmed.contains("<file") {
313            // Save previous file if exists
314            if let Some(file_path) = current_file.take() {
315                if lines_total > 0 {
316                    file_coverage.push(FileCoverage {
317                        path: PathBuf::from(file_path),
318                        lines_total,
319                        lines_covered,
320                        branches_total,
321                        branches_covered,
322                    });
323                }
324                lines_total = 0;
325                lines_covered = 0;
326                branches_total = 0;
327                branches_covered = 0;
328            }
329
330            // Extract filename
331            if let Some(filename) = extract_attribute(trimmed, "filename") {
332                current_file = Some(filename);
333            } else if let Some(filename) = extract_attribute(trimmed, "name") {
334                current_file = Some(filename);
335            }
336
337            // Extract coverage metrics if present in the same tag
338            if let Some(val) = extract_attribute(trimmed, "lines-valid") {
339                lines_total = val.parse().unwrap_or(0);
340            }
341            if let Some(val) = extract_attribute(trimmed, "lines-covered") {
342                lines_covered = val.parse().unwrap_or(0);
343            }
344            if let Some(val) = extract_attribute(trimmed, "branches-valid") {
345                branches_total = val.parse().unwrap_or(0);
346            }
347            if let Some(val) = extract_attribute(trimmed, "branches-covered") {
348                branches_covered = val.parse().unwrap_or(0);
349            }
350        }
351
352        // Alternative: extract from line elements
353        if current_file.is_some() && trimmed.contains("<line") {
354            if let Some(hits) = extract_attribute(trimmed, "hits") {
355                lines_total += 1;
356                if hits.parse::<usize>().unwrap_or(0) > 0 {
357                    lines_covered += 1;
358                }
359            }
360
361            // Check for branch coverage
362            if let Some(branch) = extract_attribute(trimmed, "branch")
363                && branch == "true"
364                && let Some(condition_coverage) = extract_attribute(trimmed, "condition-coverage")
365                && let Some((covered, total)) = parse_condition_coverage(&condition_coverage)
366            {
367                branches_total += total;
368                branches_covered += covered;
369            }
370        }
371    }
372
373    // Save last file
374    if let Some(file_path) = current_file
375        && lines_total > 0
376    {
377        file_coverage.push(FileCoverage {
378            path: PathBuf::from(file_path),
379            lines_total,
380            lines_covered,
381            branches_total,
382            branches_covered,
383        });
384    }
385
386    Ok(file_coverage)
387}
388
389/// Parse Lcov format
390fn parse_lcov(path: &Path) -> Result<Vec<FileCoverage>> {
391    let content = fs::read_to_string(path).context("Failed to read Lcov file")?;
392
393    let mut file_coverage = Vec::new();
394    let mut current_file: Option<&str> = None;
395    let mut lines_total = 0;
396    let mut lines_covered = 0;
397    let mut branches_total = 0;
398    let mut branches_covered = 0;
399
400    for line in content.lines() {
401        let trimmed = line.trim();
402
403        if trimmed.starts_with("SF:") {
404            // Start of a new file
405            if let Some(file_path) = current_file.take() {
406                file_coverage.push(FileCoverage {
407                    path: PathBuf::from(file_path),
408                    lines_total,
409                    lines_covered,
410                    branches_total,
411                    branches_covered,
412                });
413                lines_total = 0;
414                lines_covered = 0;
415                branches_total = 0;
416                branches_covered = 0;
417            }
418            current_file = trimmed.strip_prefix("SF:");
419        } else if trimmed.starts_with("DA:")
420            && let Some(comma_pos) = trimmed.find(',')
421            && let Ok(count) = trimmed[comma_pos + 1..].parse::<usize>()
422        {
423            lines_total += 1;
424            if count > 0 {
425                lines_covered += 1;
426            }
427        } else if trimmed.starts_with("BRDA:") {
428            // Branch coverage: BRDA:line_number,block_number,branch_number,taken
429            branches_total += 1;
430            let parts: Vec<&str> = trimmed
431                .strip_prefix("BRDA:")
432                .expect("")
433                .split(',')
434                .collect();
435            if parts.len() >= 4 {
436                let taken = parts[3];
437                if taken != "-" && taken != "0" {
438                    branches_covered += 1;
439                }
440            }
441        } else if trimmed.starts_with("LF:")
442            && let Ok(count) = trimmed[3..].parse::<usize>()
443        {
444            lines_total = count;
445        } else if trimmed.starts_with("LH:")
446            && let Ok(count) = trimmed[3..].parse::<usize>()
447        {
448            lines_covered = count;
449        } else if trimmed.starts_with("BRF:")
450            && let Ok(count) = trimmed[4..].parse::<usize>()
451        {
452            branches_total = count;
453        } else if trimmed.starts_with("BRH:")
454            && let Ok(count) = trimmed[4..].parse::<usize>()
455        {
456            branches_covered = count;
457        } else if trimmed == "end_of_record" {
458            // End of current file record
459            if let Some(file_path) = current_file.take() {
460                file_coverage.push(FileCoverage {
461                    path: PathBuf::from(file_path),
462                    lines_total,
463                    lines_covered,
464                    branches_total,
465                    branches_covered,
466                });
467                lines_total = 0;
468                lines_covered = 0;
469                branches_total = 0;
470                branches_covered = 0;
471            }
472        }
473    }
474
475    // Save last file if not already saved
476    if let Some(file_path) = current_file {
477        file_coverage.push(FileCoverage {
478            path: PathBuf::from(file_path),
479            lines_total,
480            lines_covered,
481            branches_total,
482            branches_covered,
483        });
484    }
485
486    Ok(file_coverage)
487}
488
489/// Extract an attribute value from an XML tag
490fn extract_attribute(line: &str, attr_name: &str) -> Option<String> {
491    let pattern = format!("{}=\"", attr_name);
492    if let Some(start) = line.find(&pattern) {
493        let value_start = start + pattern.len();
494        if let Some(end) = line[value_start..].find('"') {
495            return Some(line[value_start..value_start + end].to_string());
496        }
497    }
498    None
499}
500
501/// Parse condition coverage string like "50% (1/2)"
502fn parse_condition_coverage(coverage_str: &str) -> Option<(usize, usize)> {
503    if let Some(paren_start) = coverage_str.find('(')
504        && let Some(paren_end) = coverage_str.find(')')
505    {
506        let fraction = &coverage_str[paren_start + 1..paren_end];
507        let parts: Vec<&str> = fraction.split('/').collect();
508        if parts.len() == 2 {
509            let covered = parts[0].parse().ok()?;
510            let total = parts[1].parse().ok()?;
511            return Some((covered, total));
512        }
513    }
514    None
515}
516
517/// Map coverage data to features
518pub fn map_coverage_to_features(
519    features: &[Feature],
520    coverage_map: HashMap<String, CoverageStats>,
521    base_path: &Path,
522) -> HashMap<String, CoverageStats> {
523    let mut feature_coverage: HashMap<String, CoverageStats> = HashMap::new();
524
525    // Normalize base path
526    let canonical_base = std::fs::canonicalize(base_path).ok();
527
528    for (file_path, coverage) in coverage_map {
529        // Find which feature this file belongs to
530        if let Some(feature_path) =
531            find_feature_for_file(&file_path, features, canonical_base.as_deref())
532        {
533            let stats = feature_coverage.entry(feature_path.clone()).or_default();
534
535            // Add each file's coverage to the feature
536            for (individual_file_path, file_stats) in &coverage.files {
537                // Check if this individual file belongs to the current feature
538                if let Some(file_feature) =
539                    find_feature_for_file(individual_file_path, features, canonical_base.as_deref())
540                    && file_feature == feature_path
541                {
542                    stats
543                        .files
544                        .insert(individual_file_path.clone(), file_stats.clone());
545                }
546            }
547
548            stats.merge(&coverage);
549        }
550    }
551
552    feature_coverage
553}
554
555/// Find which feature a file belongs to
556fn find_feature_for_file(
557    file_path: &str,
558    features: &[Feature],
559    _canonical_base: Option<&Path>,
560) -> Option<String> {
561    let normalized_file = normalize_path(file_path);
562
563    fn search_features(normalized_file: &str, features: &[Feature]) -> Option<String> {
564        for feature in features {
565            let normalized_feature = normalize_path(&feature.path);
566
567            // Check if file is in this feature's folder (with proper path boundary)
568            if normalized_file.starts_with(&normalized_feature) {
569                let is_exact_match = normalized_file.len() == normalized_feature.len();
570                let has_path_separator = normalized_file
571                    .get(normalized_feature.len()..normalized_feature.len() + 1)
572                    == Some("/");
573
574                if is_exact_match || has_path_separator {
575                    // Recursively check nested features first (more specific)
576                    if let Some(nested) = search_features(normalized_file, &feature.features) {
577                        return Some(nested);
578                    }
579                    return Some(feature.path.clone());
580                }
581            }
582        }
583        None
584    }
585
586    search_features(&normalized_file, features)
587}
588
589/// Normalize a path by removing leading ./ and converting to forward slashes
590fn normalize_path(path: &str) -> String {
591    let path = path.trim_start_matches("./");
592    let path = path.replace('\\', "/");
593    path.to_string()
594}
595
596#[cfg(test)]
597mod tests {
598    use super::*;
599
600    #[test]
601    fn test_find_feature_for_file_path_boundary() {
602        // Test that a feature path doesn't match files in similarly-named directories
603        let features = vec![
604            Feature {
605                name: "OffersRoute".to_string(),
606                description: String::new(),
607                owner: String::new(),
608                is_owner_inherited: false,
609                path: "airline/routes/OffersRoute".to_string(),
610                features: vec![],
611                meta: HashMap::new(),
612                changes: vec![],
613                decisions: vec![],
614                stats: None,
615            },
616            Feature {
617                name: "CruiseOffersRoute".to_string(),
618                description: String::new(),
619                owner: String::new(),
620                is_owner_inherited: false,
621                path: "cruise/routes/OffersRoute".to_string(),
622                features: vec![],
623                meta: HashMap::new(),
624                changes: vec![],
625                decisions: vec![],
626                stats: None,
627            },
628        ];
629
630        // File in cruise/routes/OffersRoute should match CruiseOffersRoute, not OffersRoute
631        let result =
632            find_feature_for_file("cruise/routes/OffersRoute/BidCard.tsx", &features, None);
633        assert_eq!(result, Some("cruise/routes/OffersRoute".to_string()));
634
635        // File in airline/routes/OffersRoute should match OffersRoute
636        let result =
637            find_feature_for_file("airline/routes/OffersRoute/BidCard.tsx", &features, None);
638        assert_eq!(result, Some("airline/routes/OffersRoute".to_string()));
639    }
640
641    #[test]
642    fn test_find_feature_for_file_no_false_prefix_match() {
643        // Test that "routes/OffersRoute" doesn't match "routes/OffersRouteExtra"
644        let features = vec![
645            Feature {
646                name: "OffersRoute".to_string(),
647                description: String::new(),
648                owner: String::new(),
649                is_owner_inherited: false,
650                path: "routes/OffersRoute".to_string(),
651                features: vec![],
652                meta: HashMap::new(),
653                changes: vec![],
654                decisions: vec![],
655                stats: None,
656            },
657            Feature {
658                name: "OffersRouteExtra".to_string(),
659                description: String::new(),
660                owner: String::new(),
661                is_owner_inherited: false,
662                path: "routes/OffersRouteExtra".to_string(),
663                features: vec![],
664                meta: HashMap::new(),
665                changes: vec![],
666                decisions: vec![],
667                stats: None,
668            },
669        ];
670
671        // File in routes/OffersRouteExtra should match OffersRouteExtra
672        let result =
673            find_feature_for_file("routes/OffersRouteExtra/Component.tsx", &features, None);
674        assert_eq!(result, Some("routes/OffersRouteExtra".to_string()));
675
676        // File in routes/OffersRoute should match OffersRoute
677        let result = find_feature_for_file("routes/OffersRoute/Component.tsx", &features, None);
678        assert_eq!(result, Some("routes/OffersRoute".to_string()));
679    }
680
681    #[test]
682    fn test_find_feature_for_file_exact_match() {
683        // Test that exact path matches work
684        let features = vec![Feature {
685            name: "MyFeature".to_string(),
686            description: String::new(),
687            owner: String::new(),
688            is_owner_inherited: false,
689            path: "src/features/MyFeature".to_string(),
690            features: vec![],
691            meta: HashMap::new(),
692            changes: vec![],
693            decisions: vec![],
694            stats: None,
695        }];
696
697        // Exact match (file IS the feature directory)
698        let result = find_feature_for_file("src/features/MyFeature", &features, None);
699        assert_eq!(result, Some("src/features/MyFeature".to_string()));
700
701        // File inside the feature
702        let result = find_feature_for_file("src/features/MyFeature/index.tsx", &features, None);
703        assert_eq!(result, Some("src/features/MyFeature".to_string()));
704    }
705
706    #[test]
707    fn test_find_feature_for_file_no_match() {
708        // Test that unrelated paths don't match
709        let features = vec![Feature {
710            name: "MyFeature".to_string(),
711            description: String::new(),
712            owner: String::new(),
713            is_owner_inherited: false,
714            path: "src/features/MyFeature".to_string(),
715            features: vec![],
716            meta: HashMap::new(),
717            changes: vec![],
718            decisions: vec![],
719            stats: None,
720        }];
721
722        // Unrelated path
723        let result = find_feature_for_file("src/other/Component.tsx", &features, None);
724        assert_eq!(result, None);
725    }
726
727    #[test]
728    fn test_find_feature_for_file_nested_features() {
729        // Test that nested features are matched recursively and the most specific one is returned
730        let features = vec![Feature {
731            name: "ParentFeature".to_string(),
732            description: String::new(),
733            owner: String::new(),
734            is_owner_inherited: false,
735            path: "src/features/ParentFeature".to_string(),
736            features: vec![
737                Feature {
738                    name: "SubFeatureA".to_string(),
739                    description: String::new(),
740                    owner: String::new(),
741                    is_owner_inherited: false,
742                    path: "src/features/ParentFeature/SubFeatureA".to_string(),
743                    features: vec![],
744                    meta: HashMap::new(),
745                    changes: vec![],
746                    decisions: vec![],
747                    stats: None,
748                },
749                Feature {
750                    name: "SubFeatureB".to_string(),
751                    description: String::new(),
752                    owner: String::new(),
753                    is_owner_inherited: false,
754                    path: "src/features/ParentFeature/SubFeatureB".to_string(),
755                    features: vec![],
756                    meta: HashMap::new(),
757                    changes: vec![],
758                    decisions: vec![],
759                    stats: None,
760                },
761            ],
762            meta: HashMap::new(),
763            changes: vec![],
764            decisions: vec![],
765            stats: None,
766        }];
767
768        // File in SubFeatureA should match SubFeatureA (most specific)
769        let result = find_feature_for_file(
770            "src/features/ParentFeature/SubFeatureA/Component.tsx",
771            &features,
772            None,
773        );
774        assert_eq!(
775            result,
776            Some("src/features/ParentFeature/SubFeatureA".to_string())
777        );
778
779        // File in SubFeatureB should match SubFeatureB (most specific)
780        let result = find_feature_for_file(
781            "src/features/ParentFeature/SubFeatureB/index.tsx",
782            &features,
783            None,
784        );
785        assert_eq!(
786            result,
787            Some("src/features/ParentFeature/SubFeatureB".to_string())
788        );
789
790        // File in ParentFeature but not in any sub-feature should match ParentFeature
791        let result = find_feature_for_file("src/features/ParentFeature/utils.tsx", &features, None);
792        assert_eq!(result, Some("src/features/ParentFeature".to_string()));
793    }
794}