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
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    // Get all commits once at the beginning for efficiency
61    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    // Check both "decision" and "decisions" folder names
69    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                // Skip README.md files and only process .md files
88                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; // If we found one of the directories, don't check the other
101        }
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            // Skip documentation directories and directories inside them
125            // Only process directories that are direct subfolders of "features"
126            if !is_documentation_directory(&path)
127                && !is_inside_documentation_directory(&path)
128                && is_direct_subfolder_of_features(&path)
129            {
130                // Try to find and read README file, use defaults if not found
131                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                    // Convert the absolute path to a repo-relative path and look up changes
144                    get_changes_for_path(&path, map).unwrap_or_default()
145                } else {
146                    Vec::new()
147                };
148
149                // Always include decisions regardless of include_changes flag
150                let decisions = read_decision_files(&path).unwrap_or_default();
151
152                // Check if this feature has nested features
153                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
182/// Get changes for a specific path from the pre-computed changes map
183fn get_changes_for_path(
184    path: &Path,
185    changes_map: &HashMap<String, Vec<Change>>,
186) -> Result<Vec<Change>> {
187    // Canonicalize the path
188    let canonical_path = std::fs::canonicalize(path)?;
189
190    // Find the repository and get the working directory
191    let repo = Repository::discover(path)?;
192    let repo_workdir = repo
193        .workdir()
194        .context("repository has no working directory")?;
195
196    // Convert to relative path from repo root
197    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    // Look up the changes in the map
204    Ok(changes_map
205        .get(&relative_path_str)
206        .cloned()
207        .unwrap_or_default())
208}