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::git_helper::get_all_commits_by_path;
8use crate::models::{Change, Feature};
9use crate::readme_parser::read_readme_info;
10
11fn is_documentation_directory(dir_path: &Path) -> bool {
12    let dir_name = dir_path
13        .file_name()
14        .and_then(|name| name.to_str())
15        .unwrap_or("");
16
17    // Common documentation directory names
18    let doc_dirs = ["docs", "__docs__", ".docs"];
19
20    doc_dirs.contains(&dir_name.to_lowercase().as_str())
21}
22
23fn is_inside_documentation_directory(dir_path: &Path) -> bool {
24    // Check if any parent directory is a documentation directory
25    for ancestor in dir_path.ancestors().skip(1) {
26        if is_documentation_directory(ancestor) {
27            return true;
28        }
29    }
30    false
31}
32
33fn is_direct_subfolder_of_features(dir_path: &Path) -> bool {
34    if let Some(parent) = dir_path.parent()
35        && let Some(parent_name) = parent.file_name().and_then(|name| name.to_str())
36    {
37        return parent_name == "features";
38    }
39    false
40}
41
42fn find_readme_file(dir_path: &Path) -> Option<std::path::PathBuf> {
43    let readme_candidates = ["README.md", "README.mdx"];
44
45    for candidate in &readme_candidates {
46        let readme_path = dir_path.join(candidate);
47        if readme_path.exists() {
48            return Some(readme_path);
49        }
50    }
51
52    None
53}
54
55/// Check if a directory has a README with `feature: true` in front matter
56fn has_feature_flag_in_readme(dir_path: &Path) -> bool {
57    if let Some(readme_path) = find_readme_file(dir_path)
58        && let Ok(content) = fs::read_to_string(&readme_path)
59    {
60        // Check if content starts with YAML front matter (---)
61        if let Some(stripped) = content.strip_prefix("---\n")
62            && let Some(end_pos) = stripped.find("\n---\n")
63        {
64            let yaml_content = &stripped[..end_pos];
65
66            // Parse YAML front matter
67            if let Ok(yaml_value) = serde_yaml::from_str::<serde_yaml::Value>(yaml_content)
68                && let Some(mapping) = yaml_value.as_mapping()
69            {
70                // Check for feature: true
71                if let Some(feature_value) =
72                    mapping.get(serde_yaml::Value::String("feature".to_string()))
73                {
74                    return feature_value.as_bool() == Some(true);
75                }
76            }
77        }
78    }
79    false
80}
81
82/// Check if a directory should be treated as a feature
83fn is_feature_directory(dir_path: &Path) -> bool {
84    // Skip documentation directories
85    if is_documentation_directory(dir_path) || is_inside_documentation_directory(dir_path) {
86        return false;
87    }
88
89    // Check if it's a direct subfolder of "features" (existing behavior)
90    if is_direct_subfolder_of_features(dir_path) {
91        return true;
92    }
93
94    // Check if the directory has a README with feature: true
95    has_feature_flag_in_readme(dir_path)
96}
97
98pub fn list_files_recursive(dir: &Path) -> Result<Vec<Feature>> {
99    list_files_recursive_impl(dir, None)
100}
101
102pub fn list_files_recursive_with_changes(dir: &Path) -> Result<Vec<Feature>> {
103    // Get all commits once at the beginning for efficiency
104    let all_commits = get_all_commits_by_path(dir).unwrap_or_default();
105    list_files_recursive_impl(dir, Some(&all_commits))
106}
107
108fn read_decision_files(feature_path: &Path) -> Result<Vec<String>> {
109    let mut decisions = Vec::new();
110
111    // Check both "decision" and "decisions" folder names
112    let decision_paths = [
113        feature_path.join(".docs").join("decisions"),
114        feature_path.join("__docs__").join("decisions"),
115    ];
116
117    for decisions_dir in &decision_paths {
118        if decisions_dir.exists() && decisions_dir.is_dir() {
119            let entries = fs::read_dir(decisions_dir).with_context(|| {
120                format!(
121                    "could not read decisions directory `{}`",
122                    decisions_dir.display()
123                )
124            })?;
125
126            for entry in entries {
127                let entry = entry?;
128                let path = entry.path();
129
130                // Skip README.md files and only process .md files
131                if path.is_file()
132                    && let Some(file_name) = path.file_name()
133                {
134                    let file_name_str = file_name.to_string_lossy();
135                    if file_name_str.ends_with(".md") && file_name_str != "README.md" {
136                        let content = fs::read_to_string(&path).with_context(|| {
137                            format!("could not read decision file `{}`", path.display())
138                        })?;
139                        decisions.push(content);
140                    }
141                }
142            }
143            break; // If we found one of the directories, don't check the other
144        }
145    }
146
147    Ok(decisions)
148}
149
150fn process_feature_directory(
151    path: &Path,
152    name: &str,
153    changes_map: Option<&HashMap<String, Vec<Change>>>,
154) -> Result<Feature> {
155    // Try to find and read README file, use defaults if not found
156    let (owner, description, mut meta) = if let Some(readme_path) = find_readme_file(path) {
157        read_readme_info(&readme_path)?
158    } else {
159        (
160            "Unknown".to_string(),
161            "".to_string(),
162            std::collections::HashMap::new(),
163        )
164    };
165
166    // Remove the 'feature' key from meta if it exists (it's redundant since we know it's a feature)
167    meta.remove("feature");
168
169    let changes = if let Some(map) = changes_map {
170        // Convert the absolute path to a repo-relative path and look up changes
171        get_changes_for_path(path, map).unwrap_or_default()
172    } else {
173        Vec::new()
174    };
175
176    // Always include decisions regardless of include_changes flag
177    let decisions = read_decision_files(path).unwrap_or_default();
178
179    // Check if this feature has nested features in a 'features' subdirectory
180    let nested_features_path = path.join("features");
181    let mut nested_features = if nested_features_path.exists() && nested_features_path.is_dir() {
182        list_files_recursive_impl(&nested_features_path, changes_map).unwrap_or_default()
183    } else {
184        Vec::new()
185    };
186
187    // Also check for nested features marked with feature: true in subdirectories
188    let entries = fs::read_dir(path)
189        .with_context(|| format!("could not read directory `{}`", path.display()))?;
190
191    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
192    entries.sort_by_key(|entry| entry.path());
193
194    for entry in entries {
195        let entry_path = entry.path();
196        let entry_name = entry_path.file_name().unwrap().to_string_lossy();
197
198        if entry_path.is_dir()
199            && entry_name != "features" // Don't process 'features' folder twice
200            && !is_documentation_directory(&entry_path)
201            && has_feature_flag_in_readme(&entry_path)
202        {
203            let nested_feature = process_feature_directory(&entry_path, &entry_name, changes_map)?;
204            nested_features.push(nested_feature);
205        }
206    }
207
208    Ok(Feature {
209        name: name.to_string(),
210        description,
211        owner,
212        path: path.to_string_lossy().to_string(),
213        features: nested_features,
214        meta,
215        changes,
216        decisions,
217    })
218}
219
220fn list_files_recursive_impl(
221    dir: &Path,
222    changes_map: Option<&HashMap<String, Vec<Change>>>,
223) -> Result<Vec<Feature>> {
224    let entries = fs::read_dir(dir)
225        .with_context(|| format!("could not read directory `{}`", dir.display()))?;
226
227    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
228    entries.sort_by_key(|entry| entry.path());
229
230    let mut features: Vec<Feature> = Vec::new();
231
232    for entry in entries {
233        let path = entry.path();
234        let name = path.file_name().unwrap().to_string_lossy();
235
236        if path.is_dir() {
237            if is_feature_directory(&path) {
238                let feature = process_feature_directory(&path, &name, changes_map)?;
239                features.push(feature);
240            } else if !is_documentation_directory(&path)
241                && !is_inside_documentation_directory(&path)
242            {
243                // Recursively search for features in non-documentation subdirectories
244                let new_features = list_files_recursive_impl(&path, changes_map)?;
245                features.extend(new_features);
246            }
247        }
248    }
249
250    Ok(features)
251}
252
253/// Get changes for a specific path from the pre-computed changes map
254fn get_changes_for_path(
255    path: &Path,
256    changes_map: &HashMap<String, Vec<Change>>,
257) -> Result<Vec<Change>> {
258    // Canonicalize the path
259    let canonical_path = std::fs::canonicalize(path)?;
260
261    // Find the repository and get the working directory
262    let repo = Repository::discover(path)?;
263    let repo_workdir = repo
264        .workdir()
265        .context("repository has no working directory")?;
266
267    // Convert to relative path from repo root
268    let relative_path = canonical_path
269        .strip_prefix(repo_workdir)
270        .context("path is not within repository")?;
271
272    let relative_path_str = relative_path.to_string_lossy().to_string();
273
274    // Look up the changes in the map
275    Ok(changes_map
276        .get(&relative_path_str)
277        .cloned()
278        .unwrap_or_default())
279}