rumdl_lib/utils/
project_root.rs1use std::path::{Path, PathBuf};
11
12const MAX_DEPTH: usize = 100;
16
17const PROJECT_MARKERS: &[&str] = &[".git", ".rumdl.toml", "pyproject.toml", ".markdownlint.json"];
20
21pub fn discover_project_root_from(start_dir: &Path) -> PathBuf {
32 let absolute_start = if start_dir.is_relative() {
33 std::env::current_dir().map_or_else(|_| start_dir.to_path_buf(), |cwd| cwd.join(start_dir))
34 } else {
35 start_dir.to_path_buf()
36 };
37
38 let mut current = absolute_start.clone();
39 for _ in 0..MAX_DEPTH {
40 if PROJECT_MARKERS.iter().any(|marker| current.join(marker).exists()) {
41 return canonicalize_or_keep(current);
42 }
43 match current.parent() {
44 Some(parent) => current = parent.to_path_buf(),
45 None => break,
46 }
47 }
48
49 canonicalize_or_keep(absolute_start)
50}
51
52fn canonicalize_or_keep(path: PathBuf) -> PathBuf {
53 path.canonicalize().unwrap_or(path)
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59 use std::fs;
60 use tempfile::tempdir;
61
62 #[test]
63 fn test_discovers_root_via_git_marker() {
64 let temp = tempdir().unwrap();
65 let root = temp.path().canonicalize().unwrap();
66 fs::create_dir_all(root.join(".git")).unwrap();
67 let nested = root.join("a").join("b").join("c");
68 fs::create_dir_all(&nested).unwrap();
69
70 assert_eq!(discover_project_root_from(&nested), root);
71 }
72
73 #[test]
74 fn test_discovers_root_via_rumdl_toml_marker() {
75 let temp = tempdir().unwrap();
76 let root = temp.path().canonicalize().unwrap();
77 fs::write(root.join(".rumdl.toml"), "").unwrap();
78 let nested = root.join("docs");
79 fs::create_dir_all(&nested).unwrap();
80
81 assert_eq!(discover_project_root_from(&nested), root);
82 }
83
84 #[test]
85 fn test_discovers_root_via_pyproject_toml_marker() {
86 let temp = tempdir().unwrap();
87 let root = temp.path().canonicalize().unwrap();
88 fs::write(root.join("pyproject.toml"), "").unwrap();
89 let nested = root.join("src");
90 fs::create_dir_all(&nested).unwrap();
91
92 assert_eq!(discover_project_root_from(&nested), root);
93 }
94
95 #[test]
96 fn test_marker_at_ancestor_wins_over_deeper_start() {
97 let temp = tempdir().unwrap();
101 let root = temp.path().canonicalize().unwrap();
102 fs::write(root.join(".git"), "stub").unwrap();
103 let deeply_nested = root.join("a").join("b").join("c").join("d");
104 fs::create_dir_all(&deeply_nested).unwrap();
105
106 assert_eq!(discover_project_root_from(&deeply_nested), root);
107 }
108
109 #[test]
110 fn test_first_marker_wins_when_nested_projects() {
111 let temp = tempdir().unwrap();
114 let outer = temp.path().canonicalize().unwrap();
115 fs::write(outer.join(".git"), "stub").unwrap();
116 let inner = outer.join("subproject");
117 fs::create_dir_all(&inner).unwrap();
118 fs::write(inner.join(".rumdl.toml"), "").unwrap();
119 let start = inner.join("docs");
120 fs::create_dir_all(&start).unwrap();
121
122 assert_eq!(discover_project_root_from(&start), inner, "closest marker should win");
123 }
124
125 #[test]
126 fn test_canonicalizes_symlinked_root() {
127 let temp = tempdir().unwrap();
128 let real_root = temp.path().canonicalize().unwrap().join("real");
129 fs::create_dir_all(&real_root).unwrap();
130 fs::create_dir_all(real_root.join(".git")).unwrap();
131
132 let link = temp.path().canonicalize().unwrap().join("link");
133 if std::os::unix::fs::symlink(&real_root, &link).is_err() {
134 return;
135 }
136
137 let from_link = discover_project_root_from(&link);
138 assert_eq!(from_link, real_root, "symlink should canonicalize to real path");
139 }
140}