features_cli/
file_scanner.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::Path;
4
5use crate::git_helper::get_commits_for_path;
6use crate::models::Feature;
7use crate::readme_parser::read_readme_info;
8
9fn is_documentation_directory(dir_path: &Path) -> bool {
10    let dir_name = dir_path
11        .file_name()
12        .and_then(|name| name.to_str())
13        .unwrap_or("");
14
15    // Common documentation directory names
16    let doc_dirs = ["docs", "__docs__", ".docs"];
17
18    doc_dirs.contains(&dir_name.to_lowercase().as_str())
19}
20
21fn is_inside_documentation_directory(dir_path: &Path) -> bool {
22    // Check if any parent directory is a documentation directory
23    for ancestor in dir_path.ancestors().skip(1) {
24        if is_documentation_directory(ancestor) {
25            return true;
26        }
27    }
28    false
29}
30
31fn is_direct_subfolder_of_features(dir_path: &Path) -> bool {
32    if let Some(parent) = dir_path.parent()
33        && let Some(parent_name) = parent.file_name().and_then(|name| name.to_str())
34    {
35        return parent_name == "features";
36    }
37    false
38}
39
40fn find_readme_file(dir_path: &Path) -> Option<std::path::PathBuf> {
41    let readme_candidates = ["README.md", "README.mdx"];
42
43    for candidate in &readme_candidates {
44        let readme_path = dir_path.join(candidate);
45        if readme_path.exists() {
46            return Some(readme_path);
47        }
48    }
49
50    None
51}
52
53pub fn list_files_recursive(dir: &Path) -> Result<Vec<Feature>> {
54    list_files_recursive_impl(dir, false)
55}
56
57pub fn list_files_recursive_with_changes(dir: &Path) -> Result<Vec<Feature>> {
58    list_files_recursive_impl(dir, true)
59}
60
61fn read_decision_files(feature_path: &Path) -> Result<Vec<String>> {
62    let mut decisions = Vec::new();
63
64    // Check both "decision" and "decisions" folder names
65    let decision_paths = [
66        feature_path.join(".docs").join("decisions"),
67        feature_path.join("__docs__").join("decisions"),
68    ];
69
70    for decisions_dir in &decision_paths {
71        if decisions_dir.exists() && decisions_dir.is_dir() {
72            let entries = fs::read_dir(decisions_dir).with_context(|| {
73                format!(
74                    "could not read decisions directory `{}`",
75                    decisions_dir.display()
76                )
77            })?;
78
79            for entry in entries {
80                let entry = entry?;
81                let path = entry.path();
82
83                // Skip README.md files and only process .md files
84                if path.is_file()
85                    && let Some(file_name) = path.file_name()
86                {
87                    let file_name_str = file_name.to_string_lossy();
88                    if file_name_str.ends_with(".md") && file_name_str != "README.md" {
89                        let content = fs::read_to_string(&path).with_context(|| {
90                            format!("could not read decision file `{}`", path.display())
91                        })?;
92                        decisions.push(content);
93                    }
94                }
95            }
96            break; // If we found one of the directories, don't check the other
97        }
98    }
99
100    Ok(decisions)
101}
102
103fn list_files_recursive_impl(dir: &Path, include_changes: bool) -> Result<Vec<Feature>> {
104    let entries = fs::read_dir(dir)
105        .with_context(|| format!("could not read directory `{}`", dir.display()))?;
106
107    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
108    entries.sort_by_key(|entry| entry.path());
109
110    let mut features: Vec<Feature> = Vec::new();
111
112    for entry in entries {
113        let path = entry.path();
114        let name = path.file_name().unwrap().to_string_lossy();
115
116        if path.is_dir() {
117            // Skip documentation directories and directories inside them
118            // Only process directories that are direct subfolders of "features"
119            if !is_documentation_directory(&path)
120                && !is_inside_documentation_directory(&path)
121                && is_direct_subfolder_of_features(&path)
122            {
123                // Try to find and read README file, use defaults if not found
124                let (owner, description, meta) = if let Some(readme_path) = find_readme_file(&path)
125                {
126                    read_readme_info(&readme_path)?
127                } else {
128                    (
129                        "Unknown".to_string(),
130                        "".to_string(),
131                        std::collections::HashMap::new(),
132                    )
133                };
134
135                let changes = if include_changes {
136                    get_commits_for_path(&path, &path.to_string_lossy()).unwrap_or_default()
137                } else {
138                    Vec::new()
139                };
140
141                let decisions = if include_changes {
142                    read_decision_files(&path).unwrap_or_default()
143                } else {
144                    Vec::new()
145                };
146
147                // Check if this feature has nested features
148                let nested_features_path = path.join("features");
149                let nested_features =
150                    if nested_features_path.exists() && nested_features_path.is_dir() {
151                        list_files_recursive_impl(&nested_features_path, include_changes)
152                            .unwrap_or_default()
153                    } else {
154                        Vec::new()
155                    };
156
157                features.push(Feature {
158                    name: name.to_string(),
159                    description,
160                    owner,
161                    path: path.to_string_lossy().to_string(),
162                    features: nested_features,
163                    meta,
164                    changes,
165                    decisions,
166                });
167            } else {
168                let new_features = list_files_recursive_impl(&path, include_changes);
169                features.extend(new_features?);
170            }
171        }
172    }
173
174    Ok(features)
175}