features_cli/
file_scanner.rs1use 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 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 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
55fn 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 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 if let Ok(yaml_value) = serde_yaml::from_str::<serde_yaml::Value>(yaml_content)
68 && let Some(mapping) = yaml_value.as_mapping()
69 {
70 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
82fn is_feature_directory(dir_path: &Path) -> bool {
84 if is_documentation_directory(dir_path) || is_inside_documentation_directory(dir_path) {
86 return false;
87 }
88
89 if is_direct_subfolder_of_features(dir_path) {
91 return true;
92 }
93
94 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 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 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 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; }
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 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 meta.remove("feature");
168
169 let changes = if let Some(map) = changes_map {
170 get_changes_for_path(path, map).unwrap_or_default()
172 } else {
173 Vec::new()
174 };
175
176 let decisions = read_decision_files(path).unwrap_or_default();
178
179 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 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" && !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 let new_features = list_files_recursive_impl(&path, changes_map)?;
245 features.extend(new_features);
246 }
247 }
248 }
249
250 Ok(features)
251}
252
253fn get_changes_for_path(
255 path: &Path,
256 changes_map: &HashMap<String, Vec<Change>>,
257) -> Result<Vec<Change>> {
258 let canonical_path = std::fs::canonicalize(path)?;
260
261 let repo = Repository::discover(path)?;
263 let repo_workdir = repo
264 .workdir()
265 .context("repository has no working directory")?;
266
267 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 Ok(changes_map
276 .get(&relative_path_str)
277 .cloned()
278 .unwrap_or_default())
279}