Skip to main content

harn_modules/
manifest_walk.rs

1//! The single upward "where does my project start" walk for every Harn
2//! frontend.
3//!
4//! Locating the nearest `harn.toml` — the question "which project governs this
5//! file?" — was answered by nine hand-rolled loops (in the CLI, the LSP,
6//! `harn doctor`, the MCP command, the VM runtime, …) that disagreed on the
7//! stop conditions. Some stopped at a `.git` boundary; others walked to the
8//! filesystem root and would silently adopt a stray `harn.toml` in `$HOME`.
9//! Some matched a *directory* named `harn.toml`; some parsed relative start
10//! paths against the wrong base. This module is the one walk they now all
11//! share, so every frontend answers the question identically.
12//!
13//! The walk, from `start` toward the filesystem root:
14//! - normalizes `start` to an absolute path against the working directory, so
15//!   a relative or not-yet-existing start still resolves against real
16//!   ancestors;
17//! - begins at `start` itself when it is a directory, otherwise at its parent;
18//! - matches only a *regular file* of the target name, never a directory;
19//! - stops at the first `.git` boundary, so a manifest in an enclosing project
20//!   or in `$HOME` is never adopted across a repository boundary;
21//! - inspects at most [`MAX_PARENT_DIRS`] directories, bounding pathological
22//!   paths (the parent chain strictly shortens, so a symlink cannot make it
23//!   loop).
24//!
25//! The traversal is **lexical**: it ascends the parent chain of the path as
26//! written and returns paths in that same identity. It deliberately does *not*
27//! canonicalize, because the returned project root feeds lexical path-prefix
28//! guards elsewhere (workspace-root write guards, sandbox roots); flipping the
29//! returned identity (e.g. macOS `/var` → `/private/var`) would desync those
30//! prefix checks. Symlinks are still resolved *where it matters*: the `.git`
31//! and target-file probes stat through symlinks at the OS layer, so the stop
32//! and match decisions are physically correct even though the returned path is
33//! lexical. A wholesale canonical cutover — with every prefix-guard site
34//! audited — is a deliberate separate change, not a side effect of this
35//! consolidation.
36
37use std::path::{Path, PathBuf};
38
39/// Filename of the Harn project manifest.
40pub const MANIFEST_FILENAME: &str = "harn.toml";
41
42/// Hard cap on how many ancestor directories the walk inspects.
43///
44/// The walk normally stops earlier, at the first `.git` boundary; this cap
45/// bounds the ascent in a very deep tree that has no `.git` at all. (The walk
46/// cannot loop: the lexical parent chain strictly shortens each step.)
47pub(crate) const MAX_PARENT_DIRS: usize = 16;
48
49/// A file located by [`find_nearest_ancestor`].
50///
51/// Named fields rather than a tuple: the walk produces both halves together,
52/// and one of the hand-rolled copies this replaces returned a bare
53/// `(PathBuf, PathBuf)` whose two positions are impossible to tell apart at a
54/// call site.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct FoundFile {
57    /// Absolute path to the located file itself.
58    pub path: PathBuf,
59    /// Directory containing the file — the project root.
60    pub dir: PathBuf,
61}
62
63/// Walk up from `start` looking for the nearest ancestor directory that
64/// directly contains a regular file named `filename`.
65///
66/// `filename` may be a multi-component relative path (e.g.
67/// `.harn/package-current.toml`); it is joined onto each candidate directory.
68///
69/// See the [module docs](self) for the exact normalization and stop
70/// conditions. Returns `None` when no match is found before a stop condition.
71pub fn find_nearest_ancestor(start: &Path, filename: impl AsRef<Path>) -> Option<FoundFile> {
72    let filename = filename.as_ref();
73    // Normalize to an absolute path so the walk works when `start` is a
74    // relative or not-yet-existing path. Kept lexical on purpose — see the
75    // module docs on why the returned identity must not be canonicalized.
76    let base = if start.is_absolute() {
77        start.to_path_buf()
78    } else {
79        std::env::current_dir()
80            .unwrap_or_else(|_| PathBuf::from("."))
81            .join(start)
82    };
83
84    let mut cursor: Option<PathBuf> = if base.is_dir() {
85        Some(base)
86    } else {
87        base.parent().map(Path::to_path_buf)
88    };
89
90    let mut steps = 0usize;
91    while let Some(dir) = cursor {
92        if steps >= MAX_PARENT_DIRS {
93            break;
94        }
95        steps += 1;
96        let candidate = dir.join(filename);
97        if candidate.is_file() {
98            return Some(FoundFile {
99                path: candidate,
100                dir,
101            });
102        }
103        // Stop at a `.git` boundary so a stray manifest in a parent project or
104        // in `$HOME` is never silently picked up.
105        if dir.join(".git").exists() {
106            break;
107        }
108        cursor = dir.parent().map(Path::to_path_buf);
109    }
110
111    None
112}
113
114/// Walk up from `start` to the nearest `harn.toml`, returning the manifest
115/// path and the directory that holds it.
116pub fn find_nearest_manifest(start: &Path) -> Option<FoundFile> {
117    find_nearest_ancestor(start, MANIFEST_FILENAME)
118}
119
120/// The project root governing `start`: the directory of the nearest
121/// `harn.toml`, or `None` when there is none before a stop condition.
122pub fn find_project_root(start: &Path) -> Option<PathBuf> {
123    find_nearest_manifest(start).map(|found| found.dir)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    fn touch(path: &Path) {
131        if let Some(parent) = path.parent() {
132            std::fs::create_dir_all(parent).unwrap();
133        }
134        std::fs::write(path, b"").unwrap();
135    }
136
137    #[test]
138    fn reports_both_the_manifest_and_its_directory() {
139        let tmp = tempfile::tempdir().unwrap();
140        let root = tmp.path().to_path_buf();
141        touch(&root.join("harn.toml"));
142        let nested = root.join("a").join("b");
143        std::fs::create_dir_all(&nested).unwrap();
144
145        let found = find_nearest_manifest(&nested).expect("manifest found from a nested dir");
146        assert_eq!(found.path, root.join("harn.toml"));
147        assert_eq!(found.dir, root);
148    }
149
150    #[test]
151    fn a_file_start_begins_at_its_parent() {
152        let tmp = tempfile::tempdir().unwrap();
153        let root = tmp.path().to_path_buf();
154        touch(&root.join("harn.toml"));
155        let file = root.join("main.harn");
156        touch(&file);
157
158        let found = find_nearest_manifest(&file).expect("manifest found from a file path");
159        assert_eq!(found.dir, root);
160    }
161
162    #[test]
163    fn stops_at_the_git_boundary() {
164        // The manifest above `.git` belongs to a different project (or to
165        // `$HOME`). The walk must refuse it — this is the divergence that made
166        // the LSP and `harn doctor` disagree with the CLI.
167        let tmp = tempfile::tempdir().unwrap();
168        let outer = tmp.path().to_path_buf();
169        touch(&outer.join("harn.toml"));
170        let project = outer.join("project");
171        std::fs::create_dir_all(project.join(".git")).unwrap();
172        let src = project.join("src");
173        std::fs::create_dir_all(&src).unwrap();
174
175        assert_eq!(
176            find_nearest_manifest(&src),
177            None,
178            "must not reach across a .git boundary"
179        );
180    }
181
182    #[test]
183    fn ignores_a_directory_named_like_the_manifest() {
184        // A *directory* named `harn.toml` must not be mistaken for a manifest.
185        let tmp = tempfile::tempdir().unwrap();
186        let root = tmp.path().to_path_buf();
187        std::fs::create_dir_all(root.join("harn.toml")).unwrap();
188
189        assert_eq!(find_nearest_manifest(&root), None);
190    }
191
192    #[test]
193    fn yields_none_when_absent() {
194        let tmp = tempfile::tempdir().unwrap();
195        let root = tmp.path().to_path_buf();
196        // Anchor the root with a `.git` so the result cannot depend on whatever
197        // sits above $TMPDIR on the host.
198        std::fs::create_dir_all(root.join(".git")).unwrap();
199        let nested = root.join("a");
200        std::fs::create_dir_all(&nested).unwrap();
201
202        assert_eq!(find_nearest_manifest(&nested), None);
203    }
204
205    #[test]
206    fn arbitrary_sentinel_filename_parameterizes() {
207        let tmp = tempfile::tempdir().unwrap();
208        let root = tmp.path().to_path_buf();
209        touch(&root.join(".harn").join("package-current.toml"));
210        let nested = root.join("pkg").join("src");
211        std::fs::create_dir_all(&nested).unwrap();
212
213        let found = find_nearest_ancestor(&nested, ".harn/package-current.toml")
214            .expect("multi-component sentinel resolves");
215        assert_eq!(found.dir, root);
216    }
217}