1use anyhow::{Context, Result};
2use git2::Repository;
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use crate::dependency_resolver::{
8 build_file_to_feature_map, collect_feature_info, resolve_feature_dependencies,
9};
10use crate::feature_metadata_detector::{self, FeatureMetadataMap};
11use crate::features_toml_parser::{find_features_toml, read_features_toml};
12use crate::git_helper::get_all_commits_by_path;
13use crate::import_detector::{ImportStatement, build_file_map, scan_file_for_imports};
14use crate::models::{Change, Feature, Stats};
15use crate::readme_parser::read_readme_info;
16
17fn is_documentation_directory(dir_path: &Path) -> bool {
18 let dir_name = dir_path
19 .file_name()
20 .and_then(|name| name.to_str())
21 .unwrap_or("");
22
23 let doc_dirs = ["docs", "__docs__", ".docs"];
25
26 doc_dirs.contains(&dir_name.to_lowercase().as_str())
27}
28
29fn is_inside_documentation_directory(dir_path: &Path) -> bool {
30 for ancestor in dir_path.ancestors().skip(1) {
32 if is_documentation_directory(ancestor) {
33 return true;
34 }
35 }
36 false
37}
38
39fn is_direct_subfolder_of_features(dir_path: &Path) -> bool {
40 if let Some(parent) = dir_path.parent()
41 && let Some(parent_name) = parent.file_name().and_then(|name| name.to_str())
42 {
43 return parent_name == "features";
44 }
45 false
46}
47
48fn find_readme_file(dir_path: &Path) -> Option<std::path::PathBuf> {
49 let readme_candidates = ["README.md", "README.mdx"];
50
51 for candidate in &readme_candidates {
52 let readme_path = dir_path.join(candidate);
53 if readme_path.exists() {
54 return Some(readme_path);
55 }
56 }
57
58 None
59}
60
61fn has_feature_flag_in_readme(dir_path: &Path) -> bool {
63 if let Some(readme_path) = find_readme_file(dir_path)
64 && let Ok(content) = fs::read_to_string(&readme_path)
65 {
66 if let Some(stripped) = content.strip_prefix("---\n")
68 && let Some(end_pos) = stripped.find("\n---\n")
69 {
70 let yaml_content = &stripped[..end_pos];
71
72 if let Ok(yaml_value) = serde_yaml::from_str::<serde_yaml::Value>(yaml_content)
74 && let Some(mapping) = yaml_value.as_mapping()
75 {
76 if let Some(feature_value) =
78 mapping.get(serde_yaml::Value::String("feature".to_string()))
79 {
80 return feature_value.as_bool() == Some(true);
81 }
82 }
83 }
84 }
85 false
86}
87
88fn is_feature_directory(dir_path: &Path) -> bool {
90 if is_documentation_directory(dir_path) || is_inside_documentation_directory(dir_path) {
92 return false;
93 }
94
95 if is_direct_subfolder_of_features(dir_path) {
97 return true;
98 }
99
100 has_feature_flag_in_readme(dir_path)
102}
103
104pub fn list_files_recursive(dir: &Path) -> Result<Vec<Feature>> {
105 let feature_metadata =
107 feature_metadata_detector::scan_directory_for_feature_metadata(dir).unwrap_or_default();
108
109 let mut features = list_files_recursive_impl(dir, dir, None, None, &feature_metadata)?;
111
112 populate_dependencies(&mut features, dir)?;
114
115 Ok(features)
116}
117
118pub fn list_files_recursive_with_changes(dir: &Path) -> Result<Vec<Feature>> {
119 let all_commits = get_all_commits_by_path(dir).unwrap_or_default();
121 let feature_metadata =
123 feature_metadata_detector::scan_directory_for_feature_metadata(dir).unwrap_or_default();
124
125 let mut features =
127 list_files_recursive_impl(dir, dir, Some(&all_commits), None, &feature_metadata)?;
128
129 populate_dependencies(&mut features, dir)?;
131
132 Ok(features)
133}
134
135fn populate_dependencies(features: &mut [Feature], base_path: &Path) -> Result<()> {
137 let file_map = build_file_map(base_path);
139
140 let mut feature_info_list = Vec::new();
142 collect_feature_info(features, None, &mut feature_info_list);
143
144 let file_to_feature_map = build_file_to_feature_map(&feature_info_list, base_path);
146
147 let mut feature_path_to_name_map = HashMap::new();
149 for info in &feature_info_list {
150 feature_path_to_name_map.insert(info.path.to_string_lossy().to_string(), info.name.clone());
151 }
152
153 let mut feature_imports: HashMap<String, Vec<ImportStatement>> = HashMap::new();
155
156 for feature_info in &feature_info_list {
157 let feature_path = base_path.join(&feature_info.path);
158 let imports = scan_feature_directory_for_imports(&feature_path);
159 feature_imports.insert(feature_info.path.to_string_lossy().to_string(), imports);
161 }
162
163 populate_dependencies_recursive(
165 features,
166 base_path,
167 &feature_imports,
168 &file_to_feature_map,
169 &feature_path_to_name_map,
170 &file_map,
171 );
172
173 Ok(())
174}
175
176fn scan_feature_directory_for_imports(feature_path: &Path) -> Vec<ImportStatement> {
178 let mut all_imports = Vec::new();
179
180 if let Ok(entries) = fs::read_dir(feature_path) {
181 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
183 paths.sort_by(|a, b| {
184 let name_a = a.file_name().unwrap_or_default().to_string_lossy();
185 let name_b = b.file_name().unwrap_or_default().to_string_lossy();
186 name_a.cmp(&name_b)
187 });
188
189 for path in paths {
190 if is_documentation_directory(&path) {
192 continue;
193 }
194
195 if path.is_file() {
196 if let Ok(imports) = scan_file_for_imports(&path) {
197 all_imports.extend(imports);
198 }
199 } else if path.is_dir() {
200 let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
202 if dir_name == "features" {
203 continue;
204 }
205
206 if is_feature_directory(&path) {
208 continue;
209 }
210
211 let nested_imports = scan_feature_directory_for_imports(&path);
213 all_imports.extend(nested_imports);
214 }
215 }
216 }
217
218 all_imports
219}
220
221fn populate_dependencies_recursive(
223 features: &mut [Feature],
224 base_path: &Path,
225 feature_imports: &HashMap<String, Vec<ImportStatement>>,
226 file_to_feature_map: &HashMap<std::path::PathBuf, String>,
227 feature_path_to_name_map: &HashMap<String, String>,
228 file_map: &HashMap<String, std::path::PathBuf>,
229) {
230 for feature in features {
231 if let Some(imports) = feature_imports.get(&feature.path) {
233 let feature_path = std::path::PathBuf::from(&feature.path);
234
235 let dependencies = resolve_feature_dependencies(
237 &feature.name,
238 &feature_path,
239 base_path,
240 imports,
241 file_to_feature_map,
242 feature_path_to_name_map,
243 file_map,
244 );
245
246 feature.dependencies = dependencies;
247 }
248
249 if !feature.features.is_empty() {
251 populate_dependencies_recursive(
252 &mut feature.features,
253 base_path,
254 feature_imports,
255 file_to_feature_map,
256 feature_path_to_name_map,
257 file_map,
258 );
259 }
260 }
261}
262
263fn read_decision_files(feature_path: &Path) -> Result<Vec<String>> {
264 let mut decisions = Vec::new();
265
266 let decision_paths = [
268 feature_path.join(".docs").join("decisions"),
269 feature_path.join("__docs__").join("decisions"),
270 ];
271
272 for decisions_dir in &decision_paths {
273 if decisions_dir.exists() && decisions_dir.is_dir() {
274 let entries = fs::read_dir(decisions_dir).with_context(|| {
275 format!(
276 "could not read decisions directory `{}`",
277 decisions_dir.display()
278 )
279 })?;
280
281 let mut decision_paths_vec = Vec::new();
283
284 for entry in entries {
285 let entry = entry?;
286 let path = entry.path();
287
288 if path.is_file()
290 && let Some(file_name) = path.file_name()
291 {
292 let file_name_str = file_name.to_string_lossy();
293 if file_name_str.ends_with(".md") && file_name_str != "README.md" {
294 decision_paths_vec.push(path);
295 }
296 }
297 }
298
299 decision_paths_vec.sort_by(|a, b| {
301 let name_a = a.file_name().unwrap_or_default().to_string_lossy();
302 let name_b = b.file_name().unwrap_or_default().to_string_lossy();
303 name_a.cmp(&name_b)
304 });
305
306 for path in decision_paths_vec {
308 let content = fs::read_to_string(&path).with_context(|| {
309 format!("could not read decision file `{}`", path.display())
310 })?;
311 decisions.push(content);
312 }
313
314 break; }
316 }
317
318 Ok(decisions)
319}
320
321fn count_files(feature_path: &Path, nested_feature_paths: &[String]) -> usize {
323 let mut file_count = 0;
324
325 if let Ok(entries) = fs::read_dir(feature_path) {
326 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
328 paths.sort_by(|a, b| {
329 let name_a = a.file_name().unwrap_or_default().to_string_lossy();
330 let name_b = b.file_name().unwrap_or_default().to_string_lossy();
331 name_a.cmp(&name_b)
332 });
333
334 for path in paths {
335 let path_str = path.to_string_lossy().to_string();
336
337 if is_documentation_directory(&path) {
339 continue;
340 }
341
342 if nested_feature_paths
344 .iter()
345 .any(|nfp| path_str.starts_with(nfp))
346 {
347 continue;
348 }
349
350 if path.is_file() {
351 file_count += 1;
352 } else if path.is_dir() {
353 file_count += count_files(&path, nested_feature_paths);
355 }
356 }
357 }
358
359 file_count
360}
361
362fn count_lines(feature_path: &Path, nested_feature_paths: &[String]) -> usize {
364 let mut line_count = 0;
365
366 if let Ok(entries) = fs::read_dir(feature_path) {
367 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
369 paths.sort_by(|a, b| {
370 let name_a = a.file_name().unwrap_or_default().to_string_lossy();
371 let name_b = b.file_name().unwrap_or_default().to_string_lossy();
372 name_a.cmp(&name_b)
373 });
374
375 for path in paths {
376 let path_str = path.to_string_lossy().to_string();
377
378 if is_documentation_directory(&path) {
380 continue;
381 }
382
383 if nested_feature_paths
385 .iter()
386 .any(|nfp| path_str.starts_with(nfp))
387 {
388 continue;
389 }
390
391 if path.is_file() {
392 if let Ok(content) = fs::read_to_string(&path) {
394 line_count += content.lines().count();
395 }
396 } else if path.is_dir() {
397 line_count += count_lines(&path, nested_feature_paths);
399 }
400 }
401 }
402
403 line_count
404}
405
406fn count_todos(feature_path: &Path, nested_feature_paths: &[String]) -> usize {
408 let mut todo_count = 0;
409
410 if let Ok(entries) = fs::read_dir(feature_path) {
411 let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
413 paths.sort_by(|a, b| {
414 let name_a = a.file_name().unwrap_or_default().to_string_lossy();
415 let name_b = b.file_name().unwrap_or_default().to_string_lossy();
416 name_a.cmp(&name_b)
417 });
418
419 for path in paths {
420 let path_str = path.to_string_lossy().to_string();
421
422 if is_documentation_directory(&path) {
424 continue;
425 }
426
427 if nested_feature_paths
429 .iter()
430 .any(|nfp| path_str.starts_with(nfp))
431 {
432 continue;
433 }
434
435 if path.is_file() {
436 if let Ok(content) = fs::read_to_string(&path) {
438 for line in content.lines() {
439 let line_upper = line.to_uppercase();
441 if line_upper.contains("TODO") {
442 todo_count += 1;
443 }
444 }
445 }
446 } else if path.is_dir() {
447 todo_count += count_todos(&path, nested_feature_paths);
449 }
450 }
451 }
452
453 todo_count
454}
455
456fn get_commit_affected_paths(repo: &Repository, commit_hash: &str) -> Vec<String> {
458 let Ok(oid) = git2::Oid::from_str(commit_hash) else {
459 return Vec::new();
460 };
461
462 let Ok(commit) = repo.find_commit(oid) else {
463 return Vec::new();
464 };
465
466 let mut paths = Vec::new();
467
468 if commit.parent_count() == 0 {
470 if let Ok(tree) = commit.tree() {
471 collect_all_tree_paths(repo, &tree, "", &mut paths);
472 }
473 return paths;
474 }
475
476 let Ok(tree) = commit.tree() else {
478 return Vec::new();
479 };
480
481 let Ok(parent) = commit.parent(0) else {
482 return Vec::new();
483 };
484
485 let Ok(parent_tree) = parent.tree() else {
486 return Vec::new();
487 };
488
489 if let Ok(diff) = repo.diff_tree_to_tree(Some(&parent_tree), Some(&tree), None) {
490 let _ = diff.foreach(
491 &mut |delta, _| {
492 if let Some(path) = delta.new_file().path()
493 && let Some(path_str) = path.to_str()
494 {
495 paths.push(path_str.to_string());
496 }
497 if let Some(path) = delta.old_file().path()
498 && let Some(path_str) = path.to_str()
499 && !paths.contains(&path_str.to_string())
500 {
501 paths.push(path_str.to_string());
502 }
503 true
504 },
505 None,
506 None,
507 None,
508 );
509 }
510
511 paths
512}
513
514fn collect_all_tree_paths(
516 repo: &Repository,
517 tree: &git2::Tree,
518 prefix: &str,
519 paths: &mut Vec<String>,
520) {
521 for entry in tree.iter() {
522 if let Some(name) = entry.name() {
523 let path = if prefix.is_empty() {
524 name.to_string()
525 } else {
526 format!("{}/{}", prefix, name)
527 };
528
529 paths.push(path.clone());
530
531 if entry.kind() == Some(git2::ObjectType::Tree)
532 && let Ok(obj) = entry.to_object(repo)
533 && let Ok(subtree) = obj.peel_to_tree()
534 {
535 collect_all_tree_paths(repo, &subtree, &path, paths);
536 }
537 }
538 }
539}
540
541fn compute_stats_from_changes(
543 changes: &[Change],
544 feature_path: &Path,
545 nested_features: &[Feature],
546) -> Option<Stats> {
547 if changes.is_empty() {
548 return None;
549 }
550
551 let nested_feature_paths: Vec<String> =
553 nested_features.iter().map(|f| f.path.clone()).collect();
554
555 let repo = Repository::discover(feature_path).ok();
557
558 let feature_relative_path = if let Some(ref r) = repo {
560 if let Ok(canonical_path) = std::fs::canonicalize(feature_path) {
561 if let Some(workdir) = r.workdir() {
562 canonical_path
563 .strip_prefix(workdir)
564 .ok()
565 .map(|p| p.to_string_lossy().to_string())
566 } else {
567 None
568 }
569 } else {
570 None
571 }
572 } else {
573 None
574 };
575
576 let filtered_changes: Vec<&Change> = changes
579 .iter()
580 .filter(|change| {
581 let Some(ref r) = repo else {
583 return true;
584 };
585
586 let Some(ref feature_rel_path) = feature_relative_path else {
587 return true;
588 };
589
590 let affected_files = get_commit_affected_paths(r, &change.hash);
592
593 affected_files.iter().any(|file_path| {
595 let in_feature = file_path.starts_with(feature_rel_path);
597
598 let in_nested = nested_feature_paths.iter().any(|nested_path| {
600 if let Ok(nested_canonical) = std::fs::canonicalize(nested_path)
602 && let Some(workdir) = r.workdir()
603 && let Ok(nested_rel) = nested_canonical.strip_prefix(workdir)
604 {
605 let nested_rel_str = nested_rel.to_string_lossy();
606 return file_path.starts_with(nested_rel_str.as_ref());
607 }
608 false
609 });
610
611 in_feature && !in_nested
612 })
613 })
614 .collect();
615
616 let mut commits = std::collections::BTreeMap::new();
617
618 commits.insert(
620 "total_commits".to_string(),
621 serde_json::json!(filtered_changes.len()),
622 );
623
624 let mut authors_count: HashMap<String, usize> = HashMap::new();
626 for change in &filtered_changes {
627 *authors_count.entry(change.author_name.clone()).or_insert(0) += 1;
628 }
629 commits.insert(
630 "authors_count".to_string(),
631 serde_json::json!(authors_count),
632 );
633
634 let mut count_by_type: HashMap<String, usize> = HashMap::new();
636 for change in &filtered_changes {
637 let commit_type = extract_commit_type(&change.title);
638 *count_by_type.entry(commit_type).or_insert(0) += 1;
639 }
640 commits.insert(
641 "count_by_type".to_string(),
642 serde_json::json!(count_by_type),
643 );
644
645 if let Some(first) = filtered_changes.first() {
647 commits.insert(
648 "first_commit_date".to_string(),
649 serde_json::json!(first.date.clone()),
650 );
651 }
652 if let Some(last) = filtered_changes.last() {
653 commits.insert(
654 "last_commit_date".to_string(),
655 serde_json::json!(last.date.clone()),
656 );
657 }
658
659 let files_count = count_files(feature_path, &nested_feature_paths);
661 let lines_count = count_lines(feature_path, &nested_feature_paths);
662 let todos_count = count_todos(feature_path, &nested_feature_paths);
663
664 Some(Stats {
665 files_count: Some(files_count),
666 lines_count: Some(lines_count),
667 todos_count: Some(todos_count),
668 commits,
669 coverage: None,
670 })
671}
672
673fn extract_commit_type(title: &str) -> String {
675 let known_types = [
677 "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore",
678 "revert",
679 ];
680
681 if let Some(colon_pos) = title.find(':') {
683 let prefix = &title[..colon_pos];
684
685 let type_part = if let Some(paren_pos) = prefix.find('(') {
687 &prefix[..paren_pos]
688 } else {
689 prefix
690 };
691
692 let type_part = type_part.trim().to_lowercase();
693
694 if known_types.contains(&type_part.as_str()) {
696 return type_part;
697 }
698 }
699
700 "other".to_string()
702}
703
704fn process_feature_directory(
705 path: &Path,
706 base_path: &Path,
707 name: &str,
708 changes_map: Option<&HashMap<String, Vec<Change>>>,
709 parent_owner: Option<&str>,
710 feature_metadata_map: &FeatureMetadataMap,
711) -> Result<Feature> {
712 let (title, owner, description, mut meta) = if let Some(toml_path) = find_features_toml(path) {
714 if let Ok(toml_data) = read_features_toml(&toml_path) {
715 (
716 toml_data.name,
717 toml_data.owner.unwrap_or_default(),
718 toml_data.description.unwrap_or_default(),
719 toml_data.meta,
720 )
721 } else {
722 (
723 None,
724 String::new(),
725 String::new(),
726 std::collections::BTreeMap::new(),
727 )
728 }
729 } else {
730 let readme_info = if let Some(readme_path) = find_readme_file(path) {
732 read_readme_info(&readme_path)?
733 } else {
734 use crate::readme_parser::ReadmeInfo;
735 ReadmeInfo {
736 title: None,
737 owner: "".to_string(),
738 description: "".to_string(),
739 meta: std::collections::BTreeMap::new(),
740 }
741 };
742 (
743 readme_info.title,
744 readme_info.owner,
745 readme_info.description,
746 readme_info.meta,
747 )
748 };
749
750 meta.remove("feature");
752
753 let relative_path = path
755 .strip_prefix(base_path)
756 .unwrap_or(path)
757 .to_string_lossy()
758 .to_string();
759
760 if let Some(metadata_map) = feature_metadata_map.get(&relative_path) {
762 for (metadata_key, flags) in metadata_map {
764 let flags_json: Vec<serde_json::Value> = flags
766 .iter()
767 .map(|flag_map| {
768 let json_map: serde_json::Map<String, serde_json::Value> = flag_map
769 .iter()
770 .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
771 .collect();
772 serde_json::Value::Object(json_map)
773 })
774 .collect();
775
776 meta.entry(metadata_key.clone())
778 .and_modify(|existing| {
779 if let serde_json::Value::Array(arr) = existing {
780 arr.extend(flags_json.clone());
781 }
782 })
783 .or_insert_with(|| serde_json::Value::Array(flags_json));
784 }
785 }
786
787 let changes = if let Some(map) = changes_map {
788 get_changes_for_path(path, map).unwrap_or_default()
790 } else {
791 Vec::new()
792 };
793
794 let decisions = read_decision_files(path).unwrap_or_default();
796
797 let (actual_owner, is_owner_inherited) = if owner.is_empty() {
799 if let Some(parent) = parent_owner {
800 (parent.to_string(), true)
801 } else {
802 ("".to_string(), false)
803 }
804 } else {
805 (owner.clone(), false)
806 };
807
808 let nested_features_path = path.join("features");
810 let mut nested_features = if nested_features_path.exists() && nested_features_path.is_dir() {
811 list_files_recursive_impl(
812 &nested_features_path,
813 base_path,
814 changes_map,
815 Some(&actual_owner),
816 feature_metadata_map,
817 )
818 .unwrap_or_default()
819 } else {
820 Vec::new()
821 };
822
823 let entries = fs::read_dir(path)
825 .with_context(|| format!("could not read directory `{}`", path.display()))?;
826
827 let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
828 entries.sort_by_key(|entry| entry.path());
829
830 for entry in entries {
831 let entry_path = entry.path();
832 let entry_name = entry_path.file_name().unwrap().to_string_lossy();
833
834 if entry_path.is_dir()
835 && entry_name != "features" && !is_documentation_directory(&entry_path)
837 {
838 if has_feature_flag_in_readme(&entry_path) {
839 let nested_feature = process_feature_directory(
841 &entry_path,
842 base_path,
843 &entry_name,
844 changes_map,
845 Some(&actual_owner),
846 feature_metadata_map,
847 )?;
848 nested_features.push(nested_feature);
849 } else {
850 let deeper_features = list_files_recursive_impl(
853 &entry_path,
854 base_path,
855 changes_map,
856 Some(&actual_owner),
857 feature_metadata_map,
858 )?;
859 nested_features.extend(deeper_features);
860 }
861 }
862 }
863
864 let nested_feature_paths: Vec<String> =
866 nested_features.iter().map(|f| f.path.clone()).collect();
867
868 let files_count = count_files(path, &nested_feature_paths);
870 let lines_count = count_lines(path, &nested_feature_paths);
871 let todos_count = count_todos(path, &nested_feature_paths);
872
873 let stats =
875 if let Some(change_stats) = compute_stats_from_changes(&changes, path, &nested_features) {
876 Some(change_stats)
878 } else {
879 Some(Stats {
881 files_count: Some(files_count),
882 lines_count: Some(lines_count),
883 todos_count: Some(todos_count),
884 commits: std::collections::BTreeMap::new(),
885 coverage: None,
886 })
887 };
888
889 let relative_path = path
891 .strip_prefix(base_path)
892 .unwrap_or(path)
893 .to_string_lossy()
894 .to_string();
895
896 Ok(Feature {
897 name: title.unwrap_or_else(|| name.to_string()),
898 description,
899 owner: actual_owner,
900 is_owner_inherited,
901 path: relative_path,
902 features: nested_features,
903 meta,
904 changes,
905 decisions,
906 stats,
907 dependencies: Vec::new(), })
909}
910
911fn list_files_recursive_impl(
912 dir: &Path,
913 base_path: &Path,
914 changes_map: Option<&HashMap<String, Vec<Change>>>,
915 parent_owner: Option<&str>,
916 feature_metadata_map: &FeatureMetadataMap,
917) -> Result<Vec<Feature>> {
918 let entries = fs::read_dir(dir)
919 .with_context(|| format!("could not read directory `{}`", dir.display()))?;
920
921 let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
922 entries.sort_by_key(|entry| entry.path());
923
924 let mut features: Vec<Feature> = Vec::new();
925
926 for entry in entries {
927 let path = entry.path();
928 let name = path.file_name().unwrap().to_string_lossy();
929
930 if path.is_dir() {
931 if is_feature_directory(&path) {
932 let feature = process_feature_directory(
933 &path,
934 base_path,
935 &name,
936 changes_map,
937 parent_owner,
938 feature_metadata_map,
939 )?;
940 features.push(feature);
941 } else if !is_documentation_directory(&path)
942 && !is_inside_documentation_directory(&path)
943 {
944 let new_features = list_files_recursive_impl(
946 &path,
947 base_path,
948 changes_map,
949 parent_owner,
950 feature_metadata_map,
951 )?;
952 features.extend(new_features);
953 }
954 }
955 }
956
957 Ok(features)
958}
959
960fn get_changes_for_path(
962 path: &Path,
963 changes_map: &HashMap<String, Vec<Change>>,
964) -> Result<Vec<Change>> {
965 let canonical_path = std::fs::canonicalize(path)?;
967
968 let repo = Repository::discover(path)?;
970 let repo_workdir = repo
971 .workdir()
972 .context("repository has no working directory")?;
973
974 let relative_path = canonical_path
976 .strip_prefix(repo_workdir)
977 .context("path is not within repository")?;
978
979 let relative_path_str = relative_path.to_string_lossy().to_string();
980
981 Ok(changes_map
983 .get(&relative_path_str)
984 .cloned()
985 .unwrap_or_default())
986}
987
988#[cfg(test)]
989mod tests {
990 use super::*;
991
992 #[test]
993 fn test_extract_commit_type() {
994 assert_eq!(extract_commit_type("feat: add new feature"), "feat");
996 assert_eq!(extract_commit_type("fix: resolve bug"), "fix");
997 assert_eq!(extract_commit_type("docs: update README"), "docs");
998 assert_eq!(extract_commit_type("style: format code"), "style");
999 assert_eq!(
1000 extract_commit_type("refactor: improve structure"),
1001 "refactor"
1002 );
1003 assert_eq!(extract_commit_type("perf: optimize performance"), "perf");
1004 assert_eq!(extract_commit_type("test: add unit tests"), "test");
1005 assert_eq!(extract_commit_type("build: update dependencies"), "build");
1006 assert_eq!(extract_commit_type("ci: fix CI pipeline"), "ci");
1007 assert_eq!(extract_commit_type("chore: update gitignore"), "chore");
1008 assert_eq!(
1009 extract_commit_type("revert: undo previous commit"),
1010 "revert"
1011 );
1012
1013 assert_eq!(extract_commit_type("feat(auth): add login"), "feat");
1015 assert_eq!(
1016 extract_commit_type("fix(api): resolve endpoint issue"),
1017 "fix"
1018 );
1019 assert_eq!(
1020 extract_commit_type("docs(readme): update instructions"),
1021 "docs"
1022 );
1023
1024 assert_eq!(extract_commit_type("FEAT: uppercase type"), "feat");
1026 assert_eq!(extract_commit_type("Fix: mixed case"), "fix");
1027 assert_eq!(extract_commit_type("DOCS: all caps"), "docs");
1028
1029 assert_eq!(extract_commit_type("random commit message"), "other");
1031 assert_eq!(extract_commit_type("update: not conventional"), "other");
1032 assert_eq!(
1033 extract_commit_type("feature: close but not standard"),
1034 "other"
1035 );
1036 assert_eq!(extract_commit_type("no colon here"), "other");
1037 assert_eq!(extract_commit_type(""), "other");
1038
1039 assert_eq!(extract_commit_type("feat:no space after colon"), "feat");
1041 assert_eq!(extract_commit_type("feat : extra spaces"), "feat");
1042 assert_eq!(
1043 extract_commit_type("feat(scope)(weird): nested parens"),
1044 "feat"
1045 );
1046 }
1047}