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};
11use std::sync::LazyLock;
12
13use super::upward_walk::{UpwardWalk, absolutize};
14
15/// Markers that anchor a project root, in priority order.
16/// The first directory that contains any of these is the project root.
17const PROJECT_MARKERS: &[&str] = &[".git", ".rumdl.toml", "pyproject.toml", ".markdownlint.json"];
18
19/// Backs [`project_root`], which documents it.
20static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| {
21    let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
22    discover_project_root_from(&current_dir)
23});
24
25/// The project root of the run, discovered once from the working directory.
26///
27/// This is the single answer to "which directory does a leading `/` in a link
28/// name". MD051 resolves repository-absolute link targets against it and MD057
29/// validates absolute destinations against it, so both rules have to be looking
30/// at the same directory or one would report a link the other resolves.
31///
32/// Discovery is a filesystem walk and the working directory does not change
33/// during a run, so it is done once. A rule handed an explicit base (MD057's
34/// configured `roots`, a test's `with_path`) uses that instead.
35pub fn project_root() -> &'static Path {
36    &PROJECT_ROOT
37}
38
39/// Backs [`working_directory`], which documents it.
40static WORKING_DIRECTORY: LazyLock<Option<PathBuf>> =
41    LazyLock::new(|| std::env::current_dir().ok()?.canonicalize().ok());
42
43/// The working directory of the run in canonical form, or `None` where it
44/// cannot be read.
45///
46/// Canonical because it is compared against paths built on [`project_root`],
47/// which is itself canonical; the two otherwise spell the same directory
48/// differently whenever a symlink or a Windows short name is involved. The
49/// working directory does not change during a run, so it is read once.
50pub fn working_directory() -> Option<&'static Path> {
51    WORKING_DIRECTORY.as_deref()
52}
53
54/// Discover the project root by walking up from `start_dir`.
55///
56/// Returns the directory containing the first project marker (`.git`,
57/// `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`) found while
58/// traversing parent directories. Falls back to `start_dir` itself when
59/// no marker is found.
60///
61/// The result is canonicalized to resolve symlinks; if canonicalization
62/// fails (e.g. because the path no longer exists), the un-canonicalized
63/// path is returned instead.
64pub fn discover_project_root_from(start_dir: &Path) -> PathBuf {
65    let found = UpwardWalk::new(start_dir).find(|dir| PROJECT_MARKERS.iter().any(|marker| dir.join(marker).exists()));
66    canonicalize_or_keep(found.unwrap_or_else(|| absolutize(start_dir)))
67}
68
69fn canonicalize_or_keep(path: PathBuf) -> PathBuf {
70    path.canonicalize().unwrap_or(path)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use std::fs;
77    use tempfile::tempdir;
78
79    #[test]
80    fn test_discovers_root_via_git_marker() {
81        let temp = tempdir().unwrap();
82        let root = temp.path().canonicalize().unwrap();
83        fs::create_dir_all(root.join(".git")).unwrap();
84        let nested = root.join("a").join("b").join("c");
85        fs::create_dir_all(&nested).unwrap();
86
87        assert_eq!(discover_project_root_from(&nested), root);
88    }
89
90    #[test]
91    fn test_discovers_root_via_rumdl_toml_marker() {
92        let temp = tempdir().unwrap();
93        let root = temp.path().canonicalize().unwrap();
94        fs::write(root.join(".rumdl.toml"), "").unwrap();
95        let nested = root.join("docs");
96        fs::create_dir_all(&nested).unwrap();
97
98        assert_eq!(discover_project_root_from(&nested), root);
99    }
100
101    #[test]
102    fn test_discovers_root_via_pyproject_toml_marker() {
103        let temp = tempdir().unwrap();
104        let root = temp.path().canonicalize().unwrap();
105        fs::write(root.join("pyproject.toml"), "").unwrap();
106        let nested = root.join("src");
107        fs::create_dir_all(&nested).unwrap();
108
109        assert_eq!(discover_project_root_from(&nested), root);
110    }
111
112    #[test]
113    fn test_marker_at_ancestor_wins_over_deeper_start() {
114        // When the marker sits several levels above the start directory, that
115        // ancestor is the project root — the function returns it, not the
116        // start directory or any intermediate parent.
117        let temp = tempdir().unwrap();
118        let root = temp.path().canonicalize().unwrap();
119        fs::write(root.join(".git"), "stub").unwrap();
120        let deeply_nested = root.join("a").join("b").join("c").join("d");
121        fs::create_dir_all(&deeply_nested).unwrap();
122
123        assert_eq!(discover_project_root_from(&deeply_nested), root);
124    }
125
126    #[test]
127    fn test_first_marker_wins_when_nested_projects() {
128        // When markers exist at multiple ancestor levels, the *closest* ancestor
129        // wins — the walk stops at the first marker, not the topmost.
130        let temp = tempdir().unwrap();
131        let outer = temp.path().canonicalize().unwrap();
132        fs::write(outer.join(".git"), "stub").unwrap();
133        let inner = outer.join("subproject");
134        fs::create_dir_all(&inner).unwrap();
135        fs::write(inner.join(".rumdl.toml"), "").unwrap();
136        let start = inner.join("docs");
137        fs::create_dir_all(&start).unwrap();
138
139        assert_eq!(discover_project_root_from(&start), inner, "closest marker should win");
140    }
141
142    // Uses Unix symlinks; Windows symlink creation requires elevated privileges.
143    #[cfg(unix)]
144    #[test]
145    fn test_canonicalizes_symlinked_root() {
146        let temp = tempdir().unwrap();
147        let real_root = temp.path().canonicalize().unwrap().join("real");
148        fs::create_dir_all(&real_root).unwrap();
149        fs::create_dir_all(real_root.join(".git")).unwrap();
150
151        let link = temp.path().canonicalize().unwrap().join("link");
152        if std::os::unix::fs::symlink(&real_root, &link).is_err() {
153            return;
154        }
155
156        let from_link = discover_project_root_from(&link);
157        assert_eq!(from_link, real_root, "symlink should canonicalize to real path");
158    }
159}