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, the start directory is returned as a
7//! sensible fallback. The result is canonicalized when possible so callers
8//! get a stable, symlink-resolved path.
9
10use std::path::{Path, PathBuf};
11
12use super::upward_walk::{UpwardWalk, absolutize};
13
14/// Markers that anchor a project root, in priority order.
15/// The first directory that contains any of these is the project root.
16const PROJECT_MARKERS: &[&str] = &[".git", ".rumdl.toml", "pyproject.toml", ".markdownlint.json"];
17
18/// Discover the project root by walking up from `start_dir`.
19///
20/// Returns the directory containing the first project marker (`.git`,
21/// `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`) found while
22/// traversing parent directories. Falls back to `start_dir` itself when
23/// no marker is found.
24///
25/// The result is canonicalized to resolve symlinks; if canonicalization
26/// fails (e.g. because the path no longer exists), the un-canonicalized
27/// path is returned instead.
28pub fn discover_project_root_from(start_dir: &Path) -> PathBuf {
29    let found = UpwardWalk::new(start_dir).find(|dir| PROJECT_MARKERS.iter().any(|marker| dir.join(marker).exists()));
30    canonicalize_or_keep(found.unwrap_or_else(|| absolutize(start_dir)))
31}
32
33fn canonicalize_or_keep(path: PathBuf) -> PathBuf {
34    path.canonicalize().unwrap_or(path)
35}
36
37#[cfg(test)]
38mod tests {
39    use super::*;
40    use std::fs;
41    use tempfile::tempdir;
42
43    #[test]
44    fn test_discovers_root_via_git_marker() {
45        let temp = tempdir().unwrap();
46        let root = temp.path().canonicalize().unwrap();
47        fs::create_dir_all(root.join(".git")).unwrap();
48        let nested = root.join("a").join("b").join("c");
49        fs::create_dir_all(&nested).unwrap();
50
51        assert_eq!(discover_project_root_from(&nested), root);
52    }
53
54    #[test]
55    fn test_discovers_root_via_rumdl_toml_marker() {
56        let temp = tempdir().unwrap();
57        let root = temp.path().canonicalize().unwrap();
58        fs::write(root.join(".rumdl.toml"), "").unwrap();
59        let nested = root.join("docs");
60        fs::create_dir_all(&nested).unwrap();
61
62        assert_eq!(discover_project_root_from(&nested), root);
63    }
64
65    #[test]
66    fn test_discovers_root_via_pyproject_toml_marker() {
67        let temp = tempdir().unwrap();
68        let root = temp.path().canonicalize().unwrap();
69        fs::write(root.join("pyproject.toml"), "").unwrap();
70        let nested = root.join("src");
71        fs::create_dir_all(&nested).unwrap();
72
73        assert_eq!(discover_project_root_from(&nested), root);
74    }
75
76    #[test]
77    fn test_marker_at_ancestor_wins_over_deeper_start() {
78        // When the marker sits several levels above the start directory, that
79        // ancestor is the project root — the function returns it, not the
80        // start directory or any intermediate parent.
81        let temp = tempdir().unwrap();
82        let root = temp.path().canonicalize().unwrap();
83        fs::write(root.join(".git"), "stub").unwrap();
84        let deeply_nested = root.join("a").join("b").join("c").join("d");
85        fs::create_dir_all(&deeply_nested).unwrap();
86
87        assert_eq!(discover_project_root_from(&deeply_nested), root);
88    }
89
90    #[test]
91    fn test_first_marker_wins_when_nested_projects() {
92        // When markers exist at multiple ancestor levels, the *closest* ancestor
93        // wins — the walk stops at the first marker, not the topmost.
94        let temp = tempdir().unwrap();
95        let outer = temp.path().canonicalize().unwrap();
96        fs::write(outer.join(".git"), "stub").unwrap();
97        let inner = outer.join("subproject");
98        fs::create_dir_all(&inner).unwrap();
99        fs::write(inner.join(".rumdl.toml"), "").unwrap();
100        let start = inner.join("docs");
101        fs::create_dir_all(&start).unwrap();
102
103        assert_eq!(discover_project_root_from(&start), inner, "closest marker should win");
104    }
105
106    // Uses Unix symlinks; Windows symlink creation requires elevated privileges.
107    #[cfg(unix)]
108    #[test]
109    fn test_canonicalizes_symlinked_root() {
110        let temp = tempdir().unwrap();
111        let real_root = temp.path().canonicalize().unwrap().join("real");
112        fs::create_dir_all(&real_root).unwrap();
113        fs::create_dir_all(real_root.join(".git")).unwrap();
114
115        let link = temp.path().canonicalize().unwrap().join("link");
116        if std::os::unix::fs::symlink(&real_root, &link).is_err() {
117            return;
118        }
119
120        let from_link = discover_project_root_from(&link);
121        assert_eq!(from_link, real_root, "symlink should canonicalize to real path");
122    }
123}