Skip to main content

rumdl_lib/utils/
project_root.rs

1//! Project root discovery for resolving project-relative paths.
2//!
3//! Walks up the directory tree from a starting point looking for a project
4//! marker (`.git`, `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`).
5//! When a marker is found, its containing directory is returned as the project
6//! root. When no marker is found within `MAX_DEPTH` levels, the start directory
7//! is returned as a sensible fallback. The result is canonicalized when
8//! possible so callers get a stable, symlink-resolved path.
9
10use std::path::{Path, PathBuf};
11
12/// Maximum number of parent directories to traverse before giving up.
13/// Matches `Config::find_project_root_from` to keep the two implementations
14/// consistent for the same input.
15const MAX_DEPTH: usize = 100;
16
17/// Markers that anchor a project root, in priority order.
18/// The first directory that contains any of these is the project root.
19const PROJECT_MARKERS: &[&str] = &[".git", ".rumdl.toml", "pyproject.toml", ".markdownlint.json"];
20
21/// Discover the project root by walking up from `start_dir`.
22///
23/// Returns the directory containing the first project marker (`.git`,
24/// `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`) found while
25/// traversing parent directories. Falls back to `start_dir` itself when
26/// no marker is found.
27///
28/// The result is canonicalized to resolve symlinks; if canonicalization
29/// fails (e.g. because the path no longer exists), the un-canonicalized
30/// path is returned instead.
31pub 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        // When the marker sits several levels above the start directory, that
98        // ancestor is the project root — the function returns it, not the
99        // start directory or any intermediate parent.
100        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        // When markers exist at multiple ancestor levels, the *closest* ancestor
112        // wins — the walk stops at the first marker, not the topmost.
113        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}