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
55pub fn list_files_recursive(dir: &Path) -> Result<Vec<Feature>> {
56 list_files_recursive_impl(dir, None)
57}
58
59pub fn list_files_recursive_with_changes(dir: &Path) -> Result<Vec<Feature>> {
60 let all_commits = get_all_commits_by_path(dir).unwrap_or_default();
62 list_files_recursive_impl(dir, Some(&all_commits))
63}
64
65fn read_decision_files(feature_path: &Path) -> Result<Vec<String>> {
66 let mut decisions = Vec::new();
67
68 let decision_paths = [
70 feature_path.join(".docs").join("decisions"),
71 feature_path.join("__docs__").join("decisions"),
72 ];
73
74 for decisions_dir in &decision_paths {
75 if decisions_dir.exists() && decisions_dir.is_dir() {
76 let entries = fs::read_dir(decisions_dir).with_context(|| {
77 format!(
78 "could not read decisions directory `{}`",
79 decisions_dir.display()
80 )
81 })?;
82
83 for entry in entries {
84 let entry = entry?;
85 let path = entry.path();
86
87 if path.is_file()
89 && let Some(file_name) = path.file_name()
90 {
91 let file_name_str = file_name.to_string_lossy();
92 if file_name_str.ends_with(".md") && file_name_str != "README.md" {
93 let content = fs::read_to_string(&path).with_context(|| {
94 format!("could not read decision file `{}`", path.display())
95 })?;
96 decisions.push(content);
97 }
98 }
99 }
100 break; }
102 }
103
104 Ok(decisions)
105}
106
107fn list_files_recursive_impl(
108 dir: &Path,
109 changes_map: Option<&HashMap<String, Vec<Change>>>,
110) -> Result<Vec<Feature>> {
111 let entries = fs::read_dir(dir)
112 .with_context(|| format!("could not read directory `{}`", dir.display()))?;
113
114 let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
115 entries.sort_by_key(|entry| entry.path());
116
117 let mut features: Vec<Feature> = Vec::new();
118
119 for entry in entries {
120 let path = entry.path();
121 let name = path.file_name().unwrap().to_string_lossy();
122
123 if path.is_dir() {
124 if !is_documentation_directory(&path)
127 && !is_inside_documentation_directory(&path)
128 && is_direct_subfolder_of_features(&path)
129 {
130 let (owner, description, meta) = if let Some(readme_path) = find_readme_file(&path)
132 {
133 read_readme_info(&readme_path)?
134 } else {
135 (
136 "Unknown".to_string(),
137 "".to_string(),
138 std::collections::HashMap::new(),
139 )
140 };
141
142 let changes = if let Some(map) = changes_map {
143 get_changes_for_path(&path, map).unwrap_or_default()
145 } else {
146 Vec::new()
147 };
148
149 let decisions = read_decision_files(&path).unwrap_or_default();
151
152 let nested_features_path = path.join("features");
154 let nested_features =
155 if nested_features_path.exists() && nested_features_path.is_dir() {
156 list_files_recursive_impl(&nested_features_path, changes_map)
157 .unwrap_or_default()
158 } else {
159 Vec::new()
160 };
161
162 features.push(Feature {
163 name: name.to_string(),
164 description,
165 owner,
166 path: path.to_string_lossy().to_string(),
167 features: nested_features,
168 meta,
169 changes,
170 decisions,
171 });
172 } else {
173 let new_features = list_files_recursive_impl(&path, changes_map);
174 features.extend(new_features?);
175 }
176 }
177 }
178
179 Ok(features)
180}
181
182fn get_changes_for_path(
184 path: &Path,
185 changes_map: &HashMap<String, Vec<Change>>,
186) -> Result<Vec<Change>> {
187 let canonical_path = std::fs::canonicalize(path)?;
189
190 let repo = Repository::discover(path)?;
192 let repo_workdir = repo
193 .workdir()
194 .context("repository has no working directory")?;
195
196 let relative_path = canonical_path
198 .strip_prefix(repo_workdir)
199 .context("path is not within repository")?;
200
201 let relative_path_str = relative_path.to_string_lossy().to_string();
202
203 Ok(changes_map
205 .get(&relative_path_str)
206 .cloned()
207 .unwrap_or_default())
208}