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, Stats};
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
150/// Count the number of files in a feature directory (excluding documentation)
151fn count_files(feature_path: &Path) -> usize {
152    let mut file_count = 0;
153
154    if let Ok(entries) = fs::read_dir(feature_path) {
155        for entry in entries.flatten() {
156            let path = entry.path();
157
158            // Skip documentation directories
159            if is_documentation_directory(&path) {
160                continue;
161            }
162
163            if path.is_file() {
164                file_count += 1;
165            } else if path.is_dir() {
166                // Recursively count files in subdirectories
167                file_count += count_files(&path);
168            }
169        }
170    }
171
172    file_count
173}
174
175/// Count the total number of lines in all files in a feature directory (excluding documentation)
176fn count_lines(feature_path: &Path) -> usize {
177    let mut line_count = 0;
178
179    if let Ok(entries) = fs::read_dir(feature_path) {
180        for entry in entries.flatten() {
181            let path = entry.path();
182
183            // Skip documentation directories
184            if is_documentation_directory(&path) {
185                continue;
186            }
187
188            if path.is_file() {
189                // Try to read the file and count lines
190                if let Ok(content) = fs::read_to_string(&path) {
191                    line_count += content.lines().count();
192                }
193            } else if path.is_dir() {
194                // Recursively count lines in subdirectories
195                line_count += count_lines(&path);
196            }
197        }
198    }
199
200    line_count
201}
202
203/// Compute statistics from changes for a feature
204fn compute_stats_from_changes(changes: &[Change], feature_path: &Path) -> Option<Stats> {
205    if changes.is_empty() {
206        return None;
207    }
208
209    let mut commits = HashMap::new();
210
211    // Add total commit count
212    commits.insert(
213        "total_commits".to_string(),
214        serde_json::json!(changes.len()),
215    );
216
217    // Count commits by author
218    let mut authors_count: HashMap<String, usize> = HashMap::new();
219    for change in changes {
220        *authors_count.entry(change.author_name.clone()).or_insert(0) += 1;
221    }
222    commits.insert(
223        "authors_count".to_string(),
224        serde_json::json!(authors_count),
225    );
226
227    // Count commits by conventional commit type
228    let mut count_by_type: HashMap<String, usize> = HashMap::new();
229    for change in changes {
230        let commit_type = extract_commit_type(&change.title);
231        *count_by_type.entry(commit_type).or_insert(0) += 1;
232    }
233    commits.insert(
234        "count_by_type".to_string(),
235        serde_json::json!(count_by_type),
236    );
237
238    // Get first and last commit dates
239    if let Some(first) = changes.first() {
240        commits.insert(
241            "first_commit_date".to_string(),
242            serde_json::json!(first.date.clone()),
243        );
244    }
245    if let Some(last) = changes.last() {
246        commits.insert(
247            "last_commit_date".to_string(),
248            serde_json::json!(last.date.clone()),
249        );
250    }
251
252    // Count files and lines in the feature directory
253    let files_count = count_files(feature_path);
254    let lines_count = count_lines(feature_path);
255
256    Some(Stats {
257        files_count: Some(files_count),
258        lines_count: Some(lines_count),
259        commits,
260    })
261}
262
263/// Extract the commit type from a conventional commit title
264fn extract_commit_type(title: &str) -> String {
265    // Common conventional commit types
266    let known_types = [
267        "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
268        "revert",
269    ];
270
271    // Check if the title follows conventional commit format (type: description or type(scope): description)
272    if let Some(colon_pos) = title.find(':') {
273        let prefix = &title[..colon_pos];
274
275        // Remove scope if present (e.g., "feat(auth)" -> "feat")
276        let type_part = if let Some(paren_pos) = prefix.find('(') {
277            &prefix[..paren_pos]
278        } else {
279            prefix
280        };
281
282        let type_part = type_part.trim().to_lowercase();
283
284        // Check if it's a known conventional commit type
285        if known_types.contains(&type_part.as_str()) {
286            return type_part;
287        }
288    }
289
290    // If not a conventional commit, return "other"
291    "other".to_string()
292}
293
294fn process_feature_directory(
295    path: &Path,
296    name: &str,
297    changes_map: Option<&HashMap<String, Vec<Change>>>,
298) -> Result<Feature> {
299    // Try to find and read README file, use defaults if not found
300    let mut readme_info = if let Some(readme_path) = find_readme_file(path) {
301        read_readme_info(&readme_path)?
302    } else {
303        use crate::readme_parser::ReadmeInfo;
304        ReadmeInfo {
305            title: None,
306            owner: "Unknown".to_string(),
307            description: "".to_string(),
308            meta: std::collections::HashMap::new(),
309        }
310    };
311
312    // Remove the 'feature' key from meta if it exists (it's redundant since we know it's a feature)
313    readme_info.meta.remove("feature");
314
315    let changes = if let Some(map) = changes_map {
316        // Convert the absolute path to a repo-relative path and look up changes
317        get_changes_for_path(path, map).unwrap_or_default()
318    } else {
319        Vec::new()
320    };
321
322    // Always include decisions regardless of include_changes flag
323    let decisions = read_decision_files(path).unwrap_or_default();
324
325    // Check if this feature has nested features in a 'features' subdirectory
326    let nested_features_path = path.join("features");
327    let mut nested_features = if nested_features_path.exists() && nested_features_path.is_dir() {
328        list_files_recursive_impl(&nested_features_path, changes_map).unwrap_or_default()
329    } else {
330        Vec::new()
331    };
332
333    // Also check for nested features marked with feature: true in subdirectories
334    let entries = fs::read_dir(path)
335        .with_context(|| format!("could not read directory `{}`", path.display()))?;
336
337    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
338    entries.sort_by_key(|entry| entry.path());
339
340    for entry in entries {
341        let entry_path = entry.path();
342        let entry_name = entry_path.file_name().unwrap().to_string_lossy();
343
344        if entry_path.is_dir()
345            && entry_name != "features" // Don't process 'features' folder twice
346            && !is_documentation_directory(&entry_path)
347        {
348            if has_feature_flag_in_readme(&entry_path) {
349                // This directory is a feature itself
350                let nested_feature =
351                    process_feature_directory(&entry_path, &entry_name, changes_map)?;
352                nested_features.push(nested_feature);
353            } else {
354                // This directory is not a feature, but might contain features
355                // Recursively search for features inside it
356                let deeper_features = list_files_recursive_impl(&entry_path, changes_map)?;
357                nested_features.extend(deeper_features);
358            }
359        }
360    }
361
362    // Compute stats from changes if available
363    let stats = compute_stats_from_changes(&changes, path);
364
365    Ok(Feature {
366        name: readme_info.title.unwrap_or_else(|| name.to_string()),
367        description: readme_info.description,
368        owner: readme_info.owner,
369        path: path.to_string_lossy().to_string(),
370        features: nested_features,
371        meta: readme_info.meta,
372        changes,
373        decisions,
374        stats,
375    })
376}
377
378fn list_files_recursive_impl(
379    dir: &Path,
380    changes_map: Option<&HashMap<String, Vec<Change>>>,
381) -> Result<Vec<Feature>> {
382    let entries = fs::read_dir(dir)
383        .with_context(|| format!("could not read directory `{}`", dir.display()))?;
384
385    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
386    entries.sort_by_key(|entry| entry.path());
387
388    let mut features: Vec<Feature> = Vec::new();
389
390    for entry in entries {
391        let path = entry.path();
392        let name = path.file_name().unwrap().to_string_lossy();
393
394        if path.is_dir() {
395            if is_feature_directory(&path) {
396                let feature = process_feature_directory(&path, &name, changes_map)?;
397                features.push(feature);
398            } else if !is_documentation_directory(&path)
399                && !is_inside_documentation_directory(&path)
400            {
401                // Recursively search for features in non-documentation subdirectories
402                let new_features = list_files_recursive_impl(&path, changes_map)?;
403                features.extend(new_features);
404            }
405        }
406    }
407
408    Ok(features)
409}
410
411/// Get changes for a specific path from the pre-computed changes map
412fn get_changes_for_path(
413    path: &Path,
414    changes_map: &HashMap<String, Vec<Change>>,
415) -> Result<Vec<Change>> {
416    // Canonicalize the path
417    let canonical_path = std::fs::canonicalize(path)?;
418
419    // Find the repository and get the working directory
420    let repo = Repository::discover(path)?;
421    let repo_workdir = repo
422        .workdir()
423        .context("repository has no working directory")?;
424
425    // Convert to relative path from repo root
426    let relative_path = canonical_path
427        .strip_prefix(repo_workdir)
428        .context("path is not within repository")?;
429
430    let relative_path_str = relative_path.to_string_lossy().to_string();
431
432    // Look up the changes in the map
433    Ok(changes_map
434        .get(&relative_path_str)
435        .cloned()
436        .unwrap_or_default())
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_extract_commit_type() {
445        // Test standard conventional commit types
446        assert_eq!(extract_commit_type("feat: add new feature"), "feat");
447        assert_eq!(extract_commit_type("fix: resolve bug"), "fix");
448        assert_eq!(extract_commit_type("docs: update README"), "docs");
449        assert_eq!(extract_commit_type("style: format code"), "style");
450        assert_eq!(
451            extract_commit_type("refactor: improve structure"),
452            "refactor"
453        );
454        assert_eq!(extract_commit_type("perf: optimize performance"), "perf");
455        assert_eq!(extract_commit_type("test: add unit tests"), "test");
456        assert_eq!(extract_commit_type("build: update dependencies"), "build");
457        assert_eq!(extract_commit_type("ci: fix CI pipeline"), "ci");
458        assert_eq!(extract_commit_type("chore: update gitignore"), "chore");
459        assert_eq!(
460            extract_commit_type("revert: undo previous commit"),
461            "revert"
462        );
463
464        // Test with scope
465        assert_eq!(extract_commit_type("feat(auth): add login"), "feat");
466        assert_eq!(
467            extract_commit_type("fix(api): resolve endpoint issue"),
468            "fix"
469        );
470        assert_eq!(
471            extract_commit_type("docs(readme): update instructions"),
472            "docs"
473        );
474
475        // Test case insensitivity
476        assert_eq!(extract_commit_type("FEAT: uppercase type"), "feat");
477        assert_eq!(extract_commit_type("Fix: mixed case"), "fix");
478        assert_eq!(extract_commit_type("DOCS: all caps"), "docs");
479
480        // Test non-conventional commits
481        assert_eq!(extract_commit_type("random commit message"), "other");
482        assert_eq!(extract_commit_type("update: not conventional"), "other");
483        assert_eq!(
484            extract_commit_type("feature: close but not standard"),
485            "other"
486        );
487        assert_eq!(extract_commit_type("no colon here"), "other");
488        assert_eq!(extract_commit_type(""), "other");
489
490        // Test edge cases
491        assert_eq!(extract_commit_type("feat:no space after colon"), "feat");
492        assert_eq!(extract_commit_type("feat  : extra spaces"), "feat");
493        assert_eq!(
494            extract_commit_type("feat(scope)(weird): nested parens"),
495            "feat"
496        );
497    }
498}