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 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 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 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 file_count += count_files(&path);
168 }
169 }
170 }
171
172 file_count
173}
174
175fn 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 if is_documentation_directory(&path) {
185 continue;
186 }
187
188 if path.is_file() {
189 if let Ok(content) = fs::read_to_string(&path) {
191 line_count += content.lines().count();
192 }
193 } else if path.is_dir() {
194 line_count += count_lines(&path);
196 }
197 }
198 }
199
200 line_count
201}
202
203fn 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 commits.insert(
213 "total_commits".to_string(),
214 serde_json::json!(changes.len()),
215 );
216
217 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 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 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 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
263fn extract_commit_type(title: &str) -> String {
265 let known_types = [
267 "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
268 "revert",
269 ];
270
271 if let Some(colon_pos) = title.find(':') {
273 let prefix = &title[..colon_pos];
274
275 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 if known_types.contains(&type_part.as_str()) {
286 return type_part;
287 }
288 }
289
290 "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 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 readme_info.meta.remove("feature");
314
315 let changes = if let Some(map) = changes_map {
316 get_changes_for_path(path, map).unwrap_or_default()
318 } else {
319 Vec::new()
320 };
321
322 let decisions = read_decision_files(path).unwrap_or_default();
324
325 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 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" && !is_documentation_directory(&entry_path)
347 {
348 if has_feature_flag_in_readme(&entry_path) {
349 let nested_feature =
351 process_feature_directory(&entry_path, &entry_name, changes_map)?;
352 nested_features.push(nested_feature);
353 } else {
354 let deeper_features = list_files_recursive_impl(&entry_path, changes_map)?;
357 nested_features.extend(deeper_features);
358 }
359 }
360 }
361
362 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 let new_features = list_files_recursive_impl(&path, changes_map)?;
403 features.extend(new_features);
404 }
405 }
406 }
407
408 Ok(features)
409}
410
411fn get_changes_for_path(
413 path: &Path,
414 changes_map: &HashMap<String, Vec<Change>>,
415) -> Result<Vec<Change>> {
416 let canonical_path = std::fs::canonicalize(path)?;
418
419 let repo = Repository::discover(path)?;
421 let repo_workdir = repo
422 .workdir()
423 .context("repository has no working directory")?;
424
425 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 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 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 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 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 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 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}