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 removes `.` and resolves `..` components,
26//! then ascends that normalized parent chain without resolving symlinks. It
27//! deliberately does *not* canonicalize, because the returned project root
28//! feeds lexical path-prefix guards elsewhere (workspace-root write guards,
29//! sandbox roots); flipping the returned identity (e.g. macOS `/var` →
30//! `/private/var`) would desync those prefix checks. Symlinks are still resolved
31//! *where it matters*: the `.git` and target-file probes stat through symlinks
32//! at the OS layer, so the stop and match decisions are physically correct even
33//! though the returned path is lexical. A wholesale canonical cutover — with
34//! every prefix-guard site audited — is a deliberate separate change, not a
35//! side effect of this consolidation.
36
37use std::path::{Component, 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 absolute = 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 let base = normalize_lexically(&absolute);
84
85 let mut cursor: Option<PathBuf> = if base.is_dir() {
86 Some(base)
87 } else {
88 base.parent().map(Path::to_path_buf)
89 };
90
91 let mut steps = 0usize;
92 while let Some(dir) = cursor {
93 if steps >= MAX_PARENT_DIRS {
94 break;
95 }
96 steps += 1;
97 let candidate = dir.join(filename);
98 if candidate.is_file() {
99 return Some(FoundFile {
100 path: candidate,
101 dir,
102 });
103 }
104 // Stop at a `.git` boundary so a stray manifest in a parent project or
105 // in `$HOME` is never silently picked up.
106 if dir.join(".git").exists() {
107 break;
108 }
109 cursor = dir.parent().map(Path::to_path_buf);
110 }
111
112 None
113}
114
115fn normalize_lexically(path: &Path) -> PathBuf {
116 let mut normalized = PathBuf::new();
117 for component in path.components() {
118 match component {
119 Component::CurDir => {}
120 Component::ParentDir => {
121 normalized.pop();
122 }
123 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
124 Component::RootDir | Component::Normal(_) => {
125 normalized.push(component.as_os_str());
126 }
127 }
128 }
129 normalized
130}
131
132/// Walk up from `start` to the nearest `harn.toml`, returning the manifest
133/// path and the directory that holds it.
134pub fn find_nearest_manifest(start: &Path) -> Option<FoundFile> {
135 find_nearest_ancestor(start, MANIFEST_FILENAME)
136}
137
138/// The project root governing `start`: the directory of the nearest
139/// `harn.toml`, or `None` when there is none before a stop condition.
140pub fn find_project_root(start: &Path) -> Option<PathBuf> {
141 find_nearest_manifest(start).map(|found| found.dir)
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn touch(path: &Path) {
149 if let Some(parent) = path.parent() {
150 std::fs::create_dir_all(parent).unwrap();
151 }
152 std::fs::write(path, b"").unwrap();
153 }
154
155 #[test]
156 fn reports_both_the_manifest_and_its_directory() {
157 let tmp = tempfile::tempdir().unwrap();
158 let root = tmp.path().to_path_buf();
159 touch(&root.join("harn.toml"));
160 let nested = root.join("a").join("b");
161 std::fs::create_dir_all(&nested).unwrap();
162
163 let found = find_nearest_manifest(&nested).expect("manifest found from a nested dir");
164 assert_eq!(found.path, root.join("harn.toml"));
165 assert_eq!(found.dir, root);
166 }
167
168 #[test]
169 fn a_file_start_begins_at_its_parent() {
170 let tmp = tempfile::tempdir().unwrap();
171 let root = tmp.path().to_path_buf();
172 touch(&root.join("harn.toml"));
173 let file = root.join("main.harn");
174 touch(&file);
175
176 let found = find_nearest_manifest(&file).expect("manifest found from a file path");
177 assert_eq!(found.dir, root);
178 }
179
180 #[test]
181 fn normalizes_parent_components_before_walking() {
182 let tmp = tempfile::tempdir().unwrap();
183 let root = tmp.path().to_path_buf();
184 touch(&root.join("harn.toml"));
185
186 let modules = root.join("modules");
187 let mut accumulated = modules.join("00");
188 for index in 0..=10 {
189 std::fs::create_dir_all(modules.join(format!("{index:02}"))).unwrap();
190 }
191 for index in 1..=10 {
192 accumulated = accumulated.join(format!("../{index:02}"));
193 }
194
195 let found = find_nearest_manifest(&accumulated)
196 .expect("manifest found through an import-chain-accumulated path");
197 assert_eq!(found.dir, root);
198 }
199
200 #[test]
201 fn stops_at_the_git_boundary() {
202 // The manifest above `.git` belongs to a different project (or to
203 // `$HOME`). The walk must refuse it — this is the divergence that made
204 // the LSP and `harn doctor` disagree with the CLI.
205 let tmp = tempfile::tempdir().unwrap();
206 let outer = tmp.path().to_path_buf();
207 touch(&outer.join("harn.toml"));
208 let project = outer.join("project");
209 std::fs::create_dir_all(project.join(".git")).unwrap();
210 let src = project.join("src");
211 std::fs::create_dir_all(&src).unwrap();
212
213 assert_eq!(
214 find_nearest_manifest(&src),
215 None,
216 "must not reach across a .git boundary"
217 );
218 }
219
220 #[test]
221 fn ignores_a_directory_named_like_the_manifest() {
222 // A *directory* named `harn.toml` must not be mistaken for a manifest.
223 let tmp = tempfile::tempdir().unwrap();
224 let root = tmp.path().to_path_buf();
225 std::fs::create_dir_all(root.join("harn.toml")).unwrap();
226
227 assert_eq!(find_nearest_manifest(&root), None);
228 }
229
230 #[test]
231 fn yields_none_when_absent() {
232 let tmp = tempfile::tempdir().unwrap();
233 let root = tmp.path().to_path_buf();
234 // Anchor the root with a `.git` so the result cannot depend on whatever
235 // sits above $TMPDIR on the host.
236 std::fs::create_dir_all(root.join(".git")).unwrap();
237 let nested = root.join("a");
238 std::fs::create_dir_all(&nested).unwrap();
239
240 assert_eq!(find_nearest_manifest(&nested), None);
241 }
242
243 #[test]
244 fn arbitrary_sentinel_filename_parameterizes() {
245 let tmp = tempfile::tempdir().unwrap();
246 let root = tmp.path().to_path_buf();
247 touch(&root.join(".harn").join("package-current.toml"));
248 let nested = root.join("pkg").join("src");
249 std::fs::create_dir_all(&nested).unwrap();
250
251 let found = find_nearest_ancestor(&nested, ".harn/package-current.toml")
252 .expect("multi-component sentinel resolves");
253 assert_eq!(found.dir, root);
254 }
255}