Skip to main content

features_cli/
file_scanner.rs

1use anyhow::{Context, Result};
2use git2::Repository;
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use crate::dependency_resolver::{
8    build_file_to_feature_map, collect_feature_info, resolve_feature_dependencies,
9};
10use crate::feature_metadata_detector::{self, FeatureMetadataMap};
11use crate::features_toml_parser::{find_features_toml, read_features_toml};
12use crate::git_helper::get_all_commits_by_path;
13use crate::import_detector::{ImportStatement, build_file_map, scan_file_for_imports};
14use crate::models::{Change, Feature, Stats};
15use crate::readme_parser::read_readme_info;
16
17fn is_documentation_directory(dir_path: &Path) -> bool {
18    let dir_name = dir_path
19        .file_name()
20        .and_then(|name| name.to_str())
21        .unwrap_or("");
22
23    // Common documentation directory names
24    let doc_dirs = ["docs", "__docs__", ".docs"];
25
26    doc_dirs.contains(&dir_name.to_lowercase().as_str())
27}
28
29fn is_inside_documentation_directory(dir_path: &Path) -> bool {
30    // Check if any parent directory is a documentation directory
31    for ancestor in dir_path.ancestors().skip(1) {
32        if is_documentation_directory(ancestor) {
33            return true;
34        }
35    }
36    false
37}
38
39fn is_direct_subfolder_of_features(dir_path: &Path) -> bool {
40    if let Some(parent) = dir_path.parent()
41        && let Some(parent_name) = parent.file_name().and_then(|name| name.to_str())
42    {
43        return parent_name == "features";
44    }
45    false
46}
47
48fn find_readme_file(dir_path: &Path) -> Option<std::path::PathBuf> {
49    let readme_candidates = ["README.md", "README.mdx"];
50
51    for candidate in &readme_candidates {
52        let readme_path = dir_path.join(candidate);
53        if readme_path.exists() {
54            return Some(readme_path);
55        }
56    }
57
58    None
59}
60
61/// Check if a directory has a README with `feature: true` in front matter
62fn has_feature_flag_in_readme(dir_path: &Path) -> bool {
63    if let Some(readme_path) = find_readme_file(dir_path)
64        && let Ok(content) = fs::read_to_string(&readme_path)
65    {
66        // Check if content starts with YAML front matter (---)
67        if let Some(stripped) = content.strip_prefix("---\n")
68            && let Some(end_pos) = stripped.find("\n---\n")
69        {
70            let yaml_content = &stripped[..end_pos];
71
72            // Parse YAML front matter
73            if let Ok(yaml_value) = serde_yaml::from_str::<serde_yaml::Value>(yaml_content)
74                && let Some(mapping) = yaml_value.as_mapping()
75            {
76                // Check for feature: true
77                if let Some(feature_value) =
78                    mapping.get(serde_yaml::Value::String("feature".to_string()))
79                {
80                    return feature_value.as_bool() == Some(true);
81                }
82            }
83        }
84    }
85    false
86}
87
88/// Check if a directory should be treated as a feature
89fn is_feature_directory(dir_path: &Path) -> bool {
90    // Skip documentation directories
91    if is_documentation_directory(dir_path) || is_inside_documentation_directory(dir_path) {
92        return false;
93    }
94
95    // Check if it's a direct subfolder of "features" (existing behavior)
96    if is_direct_subfolder_of_features(dir_path) {
97        return true;
98    }
99
100    // Check if the directory has a README with feature: true
101    has_feature_flag_in_readme(dir_path)
102}
103
104pub fn list_files_recursive(dir: &Path) -> Result<Vec<Feature>> {
105    // Scan entire base_path for feature metadata once
106    let feature_metadata =
107        feature_metadata_detector::scan_directory_for_feature_metadata(dir).unwrap_or_default();
108
109    // First pass: build feature structure without dependencies
110    let mut features = list_files_recursive_impl(dir, dir, None, None, &feature_metadata)?;
111
112    // Second pass: scan for imports and resolve dependencies
113    populate_dependencies(&mut features, dir)?;
114
115    Ok(features)
116}
117
118pub fn list_files_recursive_with_changes(dir: &Path) -> Result<Vec<Feature>> {
119    // Get all commits once at the beginning for efficiency
120    let all_commits = get_all_commits_by_path(dir).unwrap_or_default();
121    // Scan entire base_path for feature metadata once
122    let feature_metadata =
123        feature_metadata_detector::scan_directory_for_feature_metadata(dir).unwrap_or_default();
124
125    // First pass: build feature structure without dependencies
126    let mut features =
127        list_files_recursive_impl(dir, dir, Some(&all_commits), None, &feature_metadata)?;
128
129    // Second pass: scan for imports and resolve dependencies
130    populate_dependencies(&mut features, dir)?;
131
132    Ok(features)
133}
134
135/// Populate dependencies for all features by scanning imports
136fn populate_dependencies(features: &mut [Feature], base_path: &Path) -> Result<()> {
137    // Build file map for quick path resolution
138    let file_map = build_file_map(base_path);
139
140    // Collect all feature info (flat list with paths)
141    let mut feature_info_list = Vec::new();
142    collect_feature_info(features, None, &mut feature_info_list);
143
144    // Build file-to-feature mapping
145    let file_to_feature_map = build_file_to_feature_map(&feature_info_list, base_path);
146
147    // Build feature path to name mapping (path is the unique identifier)
148    let mut feature_path_to_name_map = HashMap::new();
149    for info in &feature_info_list {
150        feature_path_to_name_map.insert(info.path.to_string_lossy().to_string(), info.name.clone());
151    }
152
153    // Scan all files in each feature for imports
154    let mut feature_imports: HashMap<String, Vec<ImportStatement>> = HashMap::new();
155
156    for feature_info in &feature_info_list {
157        let feature_path = base_path.join(&feature_info.path);
158        let imports = scan_feature_directory_for_imports(&feature_path);
159        // Use feature path as key instead of name to handle features with duplicate names
160        feature_imports.insert(feature_info.path.to_string_lossy().to_string(), imports);
161    }
162
163    // Now populate dependencies in the feature tree
164    populate_dependencies_recursive(
165        features,
166        base_path,
167        &feature_imports,
168        &file_to_feature_map,
169        &feature_path_to_name_map,
170        &file_map,
171    );
172
173    Ok(())
174}
175
176/// Scan a feature directory for all import statements
177fn scan_feature_directory_for_imports(feature_path: &Path) -> Vec<ImportStatement> {
178    let mut all_imports = Vec::new();
179
180    if let Ok(entries) = fs::read_dir(feature_path) {
181        // Collect and sort entries alphabetically by filename
182        let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
183        paths.sort_by(|a, b| {
184            let name_a = a.file_name().unwrap_or_default().to_string_lossy();
185            let name_b = b.file_name().unwrap_or_default().to_string_lossy();
186            name_a.cmp(&name_b)
187        });
188
189        for path in paths {
190            // Skip documentation directories
191            if is_documentation_directory(&path) {
192                continue;
193            }
194
195            if path.is_file() {
196                if let Ok(imports) = scan_file_for_imports(&path) {
197                    all_imports.extend(imports);
198                }
199            } else if path.is_dir() {
200                // Skip 'features' subdirectory (contains nested features)
201                let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
202                if dir_name == "features" {
203                    continue;
204                }
205
206                // Skip nested feature directories (check if it's a direct child of "features" directory with README)
207                if is_feature_directory(&path) {
208                    continue;
209                }
210
211                // Recursively scan subdirectories
212                let nested_imports = scan_feature_directory_for_imports(&path);
213                all_imports.extend(nested_imports);
214            }
215        }
216    }
217
218    all_imports
219}
220
221/// Recursively populate dependencies in the feature tree
222fn populate_dependencies_recursive(
223    features: &mut [Feature],
224    base_path: &Path,
225    feature_imports: &HashMap<String, Vec<ImportStatement>>,
226    file_to_feature_map: &HashMap<std::path::PathBuf, String>,
227    feature_path_to_name_map: &HashMap<String, String>,
228    file_map: &HashMap<String, std::path::PathBuf>,
229) {
230    for feature in features {
231        // Get imports for this feature using path as key (not name, since multiple features can have same name)
232        if let Some(imports) = feature_imports.get(&feature.path) {
233            let feature_path = std::path::PathBuf::from(&feature.path);
234
235            // Resolve dependencies
236            let dependencies = resolve_feature_dependencies(
237                &feature.name,
238                &feature_path,
239                base_path,
240                imports,
241                file_to_feature_map,
242                feature_path_to_name_map,
243                file_map,
244            );
245
246            feature.dependencies = dependencies;
247        }
248
249        // Recursively process nested features
250        if !feature.features.is_empty() {
251            populate_dependencies_recursive(
252                &mut feature.features,
253                base_path,
254                feature_imports,
255                file_to_feature_map,
256                feature_path_to_name_map,
257                file_map,
258            );
259        }
260    }
261}
262
263fn read_decision_files(feature_path: &Path) -> Result<Vec<String>> {
264    let mut decisions = Vec::new();
265
266    // Check both "decision" and "decisions" folder names
267    let decision_paths = [
268        feature_path.join(".docs").join("decisions"),
269        feature_path.join("__docs__").join("decisions"),
270    ];
271
272    for decisions_dir in &decision_paths {
273        if decisions_dir.exists() && decisions_dir.is_dir() {
274            let entries = fs::read_dir(decisions_dir).with_context(|| {
275                format!(
276                    "could not read decisions directory `{}`",
277                    decisions_dir.display()
278                )
279            })?;
280
281            // Collect all decision file paths first
282            let mut decision_paths_vec = Vec::new();
283
284            for entry in entries {
285                let entry = entry?;
286                let path = entry.path();
287
288                // Skip README.md files and only process .md files
289                if path.is_file()
290                    && let Some(file_name) = path.file_name()
291                {
292                    let file_name_str = file_name.to_string_lossy();
293                    if file_name_str.ends_with(".md") && file_name_str != "README.md" {
294                        decision_paths_vec.push(path);
295                    }
296                }
297            }
298
299            // Sort decision files alphabetically by filename
300            decision_paths_vec.sort_by(|a, b| {
301                let name_a = a.file_name().unwrap_or_default().to_string_lossy();
302                let name_b = b.file_name().unwrap_or_default().to_string_lossy();
303                name_a.cmp(&name_b)
304            });
305
306            // Read the sorted files
307            for path in decision_paths_vec {
308                let content = fs::read_to_string(&path).with_context(|| {
309                    format!("could not read decision file `{}`", path.display())
310                })?;
311                decisions.push(content);
312            }
313
314            break; // If we found one of the directories, don't check the other
315        }
316    }
317
318    Ok(decisions)
319}
320
321/// Count the number of files in a feature directory (excluding documentation)
322fn count_files(feature_path: &Path, nested_feature_paths: &[String]) -> usize {
323    let mut file_count = 0;
324
325    if let Ok(entries) = fs::read_dir(feature_path) {
326        // Collect and sort entries alphabetically by filename
327        let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
328        paths.sort_by(|a, b| {
329            let name_a = a.file_name().unwrap_or_default().to_string_lossy();
330            let name_b = b.file_name().unwrap_or_default().to_string_lossy();
331            name_a.cmp(&name_b)
332        });
333
334        for path in paths {
335            let path_str = path.to_string_lossy().to_string();
336
337            // Skip documentation directories
338            if is_documentation_directory(&path) {
339                continue;
340            }
341
342            // Skip nested feature directories
343            if nested_feature_paths
344                .iter()
345                .any(|nfp| path_str.starts_with(nfp))
346            {
347                continue;
348            }
349
350            if path.is_file() {
351                file_count += 1;
352            } else if path.is_dir() {
353                // Recursively count files in subdirectories
354                file_count += count_files(&path, nested_feature_paths);
355            }
356        }
357    }
358
359    file_count
360}
361
362/// Count the total number of lines in all files in a feature directory (excluding documentation)
363fn count_lines(feature_path: &Path, nested_feature_paths: &[String]) -> usize {
364    let mut line_count = 0;
365
366    if let Ok(entries) = fs::read_dir(feature_path) {
367        // Collect and sort entries alphabetically by filename
368        let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
369        paths.sort_by(|a, b| {
370            let name_a = a.file_name().unwrap_or_default().to_string_lossy();
371            let name_b = b.file_name().unwrap_or_default().to_string_lossy();
372            name_a.cmp(&name_b)
373        });
374
375        for path in paths {
376            let path_str = path.to_string_lossy().to_string();
377
378            // Skip documentation directories
379            if is_documentation_directory(&path) {
380                continue;
381            }
382
383            // Skip nested feature directories
384            if nested_feature_paths
385                .iter()
386                .any(|nfp| path_str.starts_with(nfp))
387            {
388                continue;
389            }
390
391            if path.is_file() {
392                // Try to read the file and count lines
393                if let Ok(content) = fs::read_to_string(&path) {
394                    line_count += content.lines().count();
395                }
396            } else if path.is_dir() {
397                // Recursively count lines in subdirectories
398                line_count += count_lines(&path, nested_feature_paths);
399            }
400        }
401    }
402
403    line_count
404}
405
406/// Count the total number of TODO comments in all files in a feature directory (excluding documentation)
407fn count_todos(feature_path: &Path, nested_feature_paths: &[String]) -> usize {
408    let mut todo_count = 0;
409
410    if let Ok(entries) = fs::read_dir(feature_path) {
411        // Collect and sort entries alphabetically by filename
412        let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
413        paths.sort_by(|a, b| {
414            let name_a = a.file_name().unwrap_or_default().to_string_lossy();
415            let name_b = b.file_name().unwrap_or_default().to_string_lossy();
416            name_a.cmp(&name_b)
417        });
418
419        for path in paths {
420            let path_str = path.to_string_lossy().to_string();
421
422            // Skip documentation directories
423            if is_documentation_directory(&path) {
424                continue;
425            }
426
427            // Skip nested feature directories
428            if nested_feature_paths
429                .iter()
430                .any(|nfp| path_str.starts_with(nfp))
431            {
432                continue;
433            }
434
435            if path.is_file() {
436                // Try to read the file and count TODO comments
437                if let Ok(content) = fs::read_to_string(&path) {
438                    for line in content.lines() {
439                        // Look for TODO in comments (case-insensitive)
440                        let line_upper = line.to_uppercase();
441                        if line_upper.contains("TODO") {
442                            todo_count += 1;
443                        }
444                    }
445                }
446            } else if path.is_dir() {
447                // Recursively count TODOs in subdirectories
448                todo_count += count_todos(&path, nested_feature_paths);
449            }
450        }
451    }
452
453    todo_count
454}
455
456/// Get the paths affected by a specific commit
457fn get_commit_affected_paths(repo: &Repository, commit_hash: &str) -> Vec<String> {
458    let Ok(oid) = git2::Oid::from_str(commit_hash) else {
459        return Vec::new();
460    };
461
462    let Ok(commit) = repo.find_commit(oid) else {
463        return Vec::new();
464    };
465
466    let mut paths = Vec::new();
467
468    // For the first commit (no parents), get all files in the tree
469    if commit.parent_count() == 0 {
470        if let Ok(tree) = commit.tree() {
471            collect_all_tree_paths(repo, &tree, "", &mut paths);
472        }
473        return paths;
474    }
475
476    // For commits with parents, check the diff
477    let Ok(tree) = commit.tree() else {
478        return Vec::new();
479    };
480
481    let Ok(parent) = commit.parent(0) else {
482        return Vec::new();
483    };
484
485    let Ok(parent_tree) = parent.tree() else {
486        return Vec::new();
487    };
488
489    if let Ok(diff) = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None) {
490        let _ = diff.foreach(
491            &mut |delta, _| {
492                if let Some(path) = delta.new_file().path()
493                    && let Some(path_str) = path.to_str()
494                {
495                    paths.push(path_str.to_string());
496                }
497                if let Some(path) = delta.old_file().path()
498                    && let Some(path_str) = path.to_str()
499                    && !paths.contains(&path_str.to_string())
500                {
501                    paths.push(path_str.to_string());
502                }
503                true
504            },
505            None,
506            None,
507            None,
508        );
509    }
510
511    paths
512}
513
514/// Collect all file paths in a tree (helper for get_commit_affected_paths)
515fn collect_all_tree_paths(
516    repo: &Repository,
517    tree: &git2::Tree,
518    prefix: &str,
519    paths: &mut Vec<String>,
520) {
521    for entry in tree.iter() {
522        if let Some(name) = entry.name() {
523            let path = if prefix.is_empty() {
524                name.to_string()
525            } else {
526                format!("{}/{}", prefix, name)
527            };
528
529            paths.push(path.clone());
530
531            if entry.kind() == Some(git2::ObjectType::Tree)
532                && let Ok(obj) = entry.to_object(repo)
533                && let Ok(subtree) = obj.peel_to_tree()
534            {
535                collect_all_tree_paths(repo, &subtree, &path, paths);
536            }
537        }
538    }
539}
540
541/// Compute statistics from changes for a feature
542fn compute_stats_from_changes(
543    changes: &[Change],
544    feature_path: &Path,
545    nested_features: &[Feature],
546) -> Option<Stats> {
547    if changes.is_empty() {
548        return None;
549    }
550
551    // Collect paths of nested features to exclude from commit counts
552    let nested_feature_paths: Vec<String> =
553        nested_features.iter().map(|f| f.path.clone()).collect();
554
555    // Get repository to check commit details
556    let repo = Repository::discover(feature_path).ok();
557
558    // Get the feature's relative path from repo root
559    let feature_relative_path = if let Some(ref r) = repo {
560        if let Ok(canonical_path) = std::fs::canonicalize(feature_path) {
561            if let Some(workdir) = r.workdir() {
562                canonical_path
563                    .strip_prefix(workdir)
564                    .ok()
565                    .map(|p| p.to_string_lossy().to_string())
566            } else {
567                None
568            }
569        } else {
570            None
571        }
572    } else {
573        None
574    };
575
576    // Filter changes to only include those that affect files in this feature
577    // (not exclusively in nested features)
578    let filtered_changes: Vec<&Change> = changes
579        .iter()
580        .filter(|change| {
581            // If we don't have repo access, include all changes
582            let Some(ref r) = repo else {
583                return true;
584            };
585
586            let Some(ref feature_rel_path) = feature_relative_path else {
587                return true;
588            };
589
590            // Get the files affected by this commit
591            let affected_files = get_commit_affected_paths(r, &change.hash);
592
593            // Check if any affected file is in this feature but not in a nested feature
594            affected_files.iter().any(|file_path| {
595                // File must be in this feature
596                let in_feature = file_path.starts_with(feature_rel_path);
597
598                // File must not be exclusively in a nested feature
599                let in_nested = nested_feature_paths.iter().any(|nested_path| {
600                    // Convert nested absolute path to relative path
601                    if let Ok(nested_canonical) = std::fs::canonicalize(nested_path)
602                        && let Some(workdir) = r.workdir()
603                        && let Ok(nested_rel) = nested_canonical.strip_prefix(workdir)
604                    {
605                        let nested_rel_str = nested_rel.to_string_lossy();
606                        return file_path.starts_with(nested_rel_str.as_ref());
607                    }
608                    false
609                });
610
611                in_feature && !in_nested
612            })
613        })
614        .collect();
615
616    let mut commits = std::collections::BTreeMap::new();
617
618    // Add total commit count
619    commits.insert(
620        "total_commits".to_string(),
621        serde_json::json!(filtered_changes.len()),
622    );
623
624    // Count commits by author
625    let mut authors_count: HashMap<String, usize> = HashMap::new();
626    for change in &filtered_changes {
627        *authors_count.entry(change.author_name.clone()).or_insert(0) += 1;
628    }
629    commits.insert(
630        "authors_count".to_string(),
631        serde_json::json!(authors_count),
632    );
633
634    // Count commits by conventional commit type
635    let mut count_by_type: HashMap<String, usize> = HashMap::new();
636    for change in &filtered_changes {
637        let commit_type = extract_commit_type(&change.title);
638        *count_by_type.entry(commit_type).or_insert(0) += 1;
639    }
640    commits.insert(
641        "count_by_type".to_string(),
642        serde_json::json!(count_by_type),
643    );
644
645    // Get first and last commit dates
646    if let Some(first) = filtered_changes.first() {
647        commits.insert(
648            "first_commit_date".to_string(),
649            serde_json::json!(first.date.clone()),
650        );
651    }
652    if let Some(last) = filtered_changes.last() {
653        commits.insert(
654            "last_commit_date".to_string(),
655            serde_json::json!(last.date.clone()),
656        );
657    }
658
659    // Count files and lines in the feature directory (excluding nested features)
660    let files_count = count_files(feature_path, &nested_feature_paths);
661    let lines_count = count_lines(feature_path, &nested_feature_paths);
662    let todos_count = count_todos(feature_path, &nested_feature_paths);
663
664    Some(Stats {
665        files_count: Some(files_count),
666        lines_count: Some(lines_count),
667        todos_count: Some(todos_count),
668        commits,
669        coverage: None,
670    })
671}
672
673/// Extract the commit type from a conventional commit title
674fn extract_commit_type(title: &str) -> String {
675    // Common conventional commit types
676    let known_types = [
677        "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
678        "revert",
679    ];
680
681    // Check if the title follows conventional commit format (type: description or type(scope): description)
682    if let Some(colon_pos) = title.find(':') {
683        let prefix = &title[..colon_pos];
684
685        // Remove scope if present (e.g., "feat(auth)" -> "feat")
686        let type_part = if let Some(paren_pos) = prefix.find('(') {
687            &prefix[..paren_pos]
688        } else {
689            prefix
690        };
691
692        let type_part = type_part.trim().to_lowercase();
693
694        // Check if it's a known conventional commit type
695        if known_types.contains(&type_part.as_str()) {
696            return type_part;
697        }
698    }
699
700    // If not a conventional commit, return "other"
701    "other".to_string()
702}
703
704fn process_feature_directory(
705    path: &Path,
706    base_path: &Path,
707    name: &str,
708    changes_map: Option<&HashMap<String, Vec<Change>>>,
709    parent_owner: Option<&str>,
710    feature_metadata_map: &FeatureMetadataMap,
711) -> Result<Feature> {
712    // First try to find and read FEATURES.toml file
713    let (title, owner, description, mut meta) = if let Some(toml_path) = find_features_toml(path) {
714        if let Ok(toml_data) = read_features_toml(&toml_path) {
715            (
716                toml_data.name,
717                toml_data.owner.unwrap_or_default(),
718                toml_data.description.unwrap_or_default(),
719                toml_data.meta,
720            )
721        } else {
722            (
723                None,
724                String::new(),
725                String::new(),
726                std::collections::BTreeMap::new(),
727            )
728        }
729    } else {
730        // Fall back to README file if FEATURES.toml not found
731        let readme_info = if let Some(readme_path) = find_readme_file(path) {
732            read_readme_info(&readme_path)?
733        } else {
734            use crate::readme_parser::ReadmeInfo;
735            ReadmeInfo {
736                title: None,
737                owner: "".to_string(),
738                description: "".to_string(),
739                meta: std::collections::BTreeMap::new(),
740            }
741        };
742        (
743            readme_info.title,
744            readme_info.owner,
745            readme_info.description,
746            readme_info.meta,
747        )
748    };
749
750    // Remove the 'feature' key from meta if it exists (it's redundant since we know it's a feature)
751    meta.remove("feature");
752
753    // Get the relative path to this feature directory for metadata lookup
754    let relative_path = path
755        .strip_prefix(base_path)
756        .unwrap_or(path)
757        .to_string_lossy()
758        .to_string();
759
760    // Check if this feature has any metadata from the global scan (matched by feature path)
761    if let Some(metadata_map) = feature_metadata_map.get(&relative_path) {
762        // Iterate through each metadata key (e.g., "feature-flag", "feature-experiment")
763        for (metadata_key, flags) in metadata_map {
764            // Convert Vec<HashMap<String, String>> to JSON array
765            let flags_json: Vec<serde_json::Value> = flags
766                .iter()
767                .map(|flag_map| {
768                    let json_map: serde_json::Map<String, serde_json::Value> = flag_map
769                        .iter()
770                        .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
771                        .collect();
772                    serde_json::Value::Object(json_map)
773                })
774                .collect();
775
776            // Check if this metadata key already exists, append if it does
777            meta.entry(metadata_key.clone())
778                .and_modify(|existing| {
779                    if let serde_json::Value::Array(arr) = existing {
780                        arr.extend(flags_json.clone());
781                    }
782                })
783                .or_insert_with(|| serde_json::Value::Array(flags_json));
784        }
785    }
786
787    let changes = if let Some(map) = changes_map {
788        // Convert the absolute path to a repo-relative path and look up changes
789        get_changes_for_path(path, map).unwrap_or_default()
790    } else {
791        Vec::new()
792    };
793
794    // Always include decisions regardless of include_changes flag
795    let decisions = read_decision_files(path).unwrap_or_default();
796
797    // Determine the actual owner and whether it's inherited
798    let (actual_owner, is_owner_inherited) = if owner.is_empty() {
799        if let Some(parent) = parent_owner {
800            (parent.to_string(), true)
801        } else {
802            ("".to_string(), false)
803        }
804    } else {
805        (owner.clone(), false)
806    };
807
808    // Check if this feature has nested features in a 'features' subdirectory
809    let nested_features_path = path.join("features");
810    let mut nested_features = if nested_features_path.exists() && nested_features_path.is_dir() {
811        list_files_recursive_impl(
812            &nested_features_path,
813            base_path,
814            changes_map,
815            Some(&actual_owner),
816            feature_metadata_map,
817        )
818        .unwrap_or_default()
819    } else {
820        Vec::new()
821    };
822
823    // Also check for nested features marked with feature: true in subdirectories
824    let entries = fs::read_dir(path)
825        .with_context(|| format!("could not read directory `{}`", path.display()))?;
826
827    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
828    entries.sort_by_key(|entry| entry.path());
829
830    for entry in entries {
831        let entry_path = entry.path();
832        let entry_name = entry_path.file_name().unwrap().to_string_lossy();
833
834        if entry_path.is_dir()
835            && entry_name != "features" // Don't process 'features' folder twice
836            && !is_documentation_directory(&entry_path)
837        {
838            if has_feature_flag_in_readme(&entry_path) {
839                // This directory is a feature itself
840                let nested_feature = process_feature_directory(
841                    &entry_path,
842                    base_path,
843                    &entry_name,
844                    changes_map,
845                    Some(&actual_owner),
846                    feature_metadata_map,
847                )?;
848                nested_features.push(nested_feature);
849            } else {
850                // This directory is not a feature, but might contain features
851                // Recursively search for features inside it
852                let deeper_features = list_files_recursive_impl(
853                    &entry_path,
854                    base_path,
855                    changes_map,
856                    Some(&actual_owner),
857                    feature_metadata_map,
858                )?;
859                nested_features.extend(deeper_features);
860            }
861        }
862    }
863
864    // Collect paths of nested features to exclude from file/line counts
865    let nested_feature_paths: Vec<String> =
866        nested_features.iter().map(|f| f.path.clone()).collect();
867
868    // Always compute file, line, and TODO counts
869    let files_count = count_files(path, &nested_feature_paths);
870    let lines_count = count_lines(path, &nested_feature_paths);
871    let todos_count = count_todos(path, &nested_feature_paths);
872
873    // Compute stats from changes if available, otherwise create basic stats
874    let stats =
875        if let Some(change_stats) = compute_stats_from_changes(&changes, path, &nested_features) {
876            // If we have change stats, they already include files/lines/todos counts
877            Some(change_stats)
878        } else {
879            // No changes, but we still want to include file/line/todo counts
880            Some(Stats {
881                files_count: Some(files_count),
882                lines_count: Some(lines_count),
883                todos_count: Some(todos_count),
884                commits: std::collections::BTreeMap::new(),
885                coverage: None,
886            })
887        };
888
889    // Make path relative to base_path
890    let relative_path = path
891        .strip_prefix(base_path)
892        .unwrap_or(path)
893        .to_string_lossy()
894        .to_string();
895
896    Ok(Feature {
897        name: title.unwrap_or_else(|| name.to_string()),
898        description,
899        owner: actual_owner,
900        is_owner_inherited,
901        path: relative_path,
902        features: nested_features,
903        meta,
904        changes,
905        decisions,
906        stats,
907        dependencies: Vec::new(), // Will be populated in second pass
908    })
909}
910
911fn list_files_recursive_impl(
912    dir: &Path,
913    base_path: &Path,
914    changes_map: Option<&HashMap<String, Vec<Change>>>,
915    parent_owner: Option<&str>,
916    feature_metadata_map: &FeatureMetadataMap,
917) -> Result<Vec<Feature>> {
918    let entries = fs::read_dir(dir)
919        .with_context(|| format!("could not read directory `{}`", dir.display()))?;
920
921    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
922    entries.sort_by_key(|entry| entry.path());
923
924    let mut features: Vec<Feature> = Vec::new();
925
926    for entry in entries {
927        let path = entry.path();
928        let name = path.file_name().unwrap().to_string_lossy();
929
930        if path.is_dir() {
931            if is_feature_directory(&path) {
932                let feature = process_feature_directory(
933                    &path,
934                    base_path,
935                    &name,
936                    changes_map,
937                    parent_owner,
938                    feature_metadata_map,
939                )?;
940                features.push(feature);
941            } else if !is_documentation_directory(&path)
942                && !is_inside_documentation_directory(&path)
943            {
944                // Recursively search for features in non-documentation subdirectories
945                let new_features = list_files_recursive_impl(
946                    &path,
947                    base_path,
948                    changes_map,
949                    parent_owner,
950                    feature_metadata_map,
951                )?;
952                features.extend(new_features);
953            }
954        }
955    }
956
957    Ok(features)
958}
959
960/// Get changes for a specific path from the pre-computed changes map
961fn get_changes_for_path(
962    path: &Path,
963    changes_map: &HashMap<String, Vec<Change>>,
964) -> Result<Vec<Change>> {
965    // Canonicalize the path
966    let canonical_path = std::fs::canonicalize(path)?;
967
968    // Find the repository and get the working directory
969    let repo = Repository::discover(path)?;
970    let repo_workdir = repo
971        .workdir()
972        .context("repository has no working directory")?;
973
974    // Convert to relative path from repo root
975    let relative_path = canonical_path
976        .strip_prefix(repo_workdir)
977        .context("path is not within repository")?;
978
979    let relative_path_str = relative_path.to_string_lossy().to_string();
980
981    // Look up the changes in the map
982    Ok(changes_map
983        .get(&relative_path_str)
984        .cloned()
985        .unwrap_or_default())
986}
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991
992    #[test]
993    fn test_extract_commit_type() {
994        // Test standard conventional commit types
995        assert_eq!(extract_commit_type("feat: add new feature"), "feat");
996        assert_eq!(extract_commit_type("fix: resolve bug"), "fix");
997        assert_eq!(extract_commit_type("docs: update README"), "docs");
998        assert_eq!(extract_commit_type("style: format code"), "style");
999        assert_eq!(
1000            extract_commit_type("refactor: improve structure"),
1001            "refactor"
1002        );
1003        assert_eq!(extract_commit_type("perf: optimize performance"), "perf");
1004        assert_eq!(extract_commit_type("test: add unit tests"), "test");
1005        assert_eq!(extract_commit_type("build: update dependencies"), "build");
1006        assert_eq!(extract_commit_type("ci: fix CI pipeline"), "ci");
1007        assert_eq!(extract_commit_type("chore: update gitignore"), "chore");
1008        assert_eq!(
1009            extract_commit_type("revert: undo previous commit"),
1010            "revert"
1011        );
1012
1013        // Test with scope
1014        assert_eq!(extract_commit_type("feat(auth): add login"), "feat");
1015        assert_eq!(
1016            extract_commit_type("fix(api): resolve endpoint issue"),
1017            "fix"
1018        );
1019        assert_eq!(
1020            extract_commit_type("docs(readme): update instructions"),
1021            "docs"
1022        );
1023
1024        // Test case insensitivity
1025        assert_eq!(extract_commit_type("FEAT: uppercase type"), "feat");
1026        assert_eq!(extract_commit_type("Fix: mixed case"), "fix");
1027        assert_eq!(extract_commit_type("DOCS: all caps"), "docs");
1028
1029        // Test non-conventional commits
1030        assert_eq!(extract_commit_type("random commit message"), "other");
1031        assert_eq!(extract_commit_type("update: not conventional"), "other");
1032        assert_eq!(
1033            extract_commit_type("feature: close but not standard"),
1034            "other"
1035        );
1036        assert_eq!(extract_commit_type("no colon here"), "other");
1037        assert_eq!(extract_commit_type(""), "other");
1038
1039        // Test edge cases
1040        assert_eq!(extract_commit_type("feat:no space after colon"), "feat");
1041        assert_eq!(extract_commit_type("feat  : extra spaces"), "feat");
1042        assert_eq!(
1043            extract_commit_type("feat(scope)(weird): nested parens"),
1044            "feat"
1045        );
1046    }
1047}