use std::path::{Path, PathBuf};
pub const MANIFEST_FILENAME: &str = "harn.toml";
pub(crate) const MAX_PARENT_DIRS: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FoundFile {
pub path: PathBuf,
pub dir: PathBuf,
}
pub fn find_nearest_ancestor(start: &Path, filename: impl AsRef<Path>) -> Option<FoundFile> {
let filename = filename.as_ref();
let base = if start.is_absolute() {
start.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(start)
};
let mut cursor: Option<PathBuf> = if base.is_dir() {
Some(base)
} else {
base.parent().map(Path::to_path_buf)
};
let mut steps = 0usize;
while let Some(dir) = cursor {
if steps >= MAX_PARENT_DIRS {
break;
}
steps += 1;
let candidate = dir.join(filename);
if candidate.is_file() {
return Some(FoundFile {
path: candidate,
dir,
});
}
if dir.join(".git").exists() {
break;
}
cursor = dir.parent().map(Path::to_path_buf);
}
None
}
pub fn find_nearest_manifest(start: &Path) -> Option<FoundFile> {
find_nearest_ancestor(start, MANIFEST_FILENAME)
}
pub fn find_project_root(start: &Path) -> Option<PathBuf> {
find_nearest_manifest(start).map(|found| found.dir)
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, b"").unwrap();
}
#[test]
fn reports_both_the_manifest_and_its_directory() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
touch(&root.join("harn.toml"));
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
let found = find_nearest_manifest(&nested).expect("manifest found from a nested dir");
assert_eq!(found.path, root.join("harn.toml"));
assert_eq!(found.dir, root);
}
#[test]
fn a_file_start_begins_at_its_parent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
touch(&root.join("harn.toml"));
let file = root.join("main.harn");
touch(&file);
let found = find_nearest_manifest(&file).expect("manifest found from a file path");
assert_eq!(found.dir, root);
}
#[test]
fn stops_at_the_git_boundary() {
let tmp = tempfile::tempdir().unwrap();
let outer = tmp.path().to_path_buf();
touch(&outer.join("harn.toml"));
let project = outer.join("project");
std::fs::create_dir_all(project.join(".git")).unwrap();
let src = project.join("src");
std::fs::create_dir_all(&src).unwrap();
assert_eq!(
find_nearest_manifest(&src),
None,
"must not reach across a .git boundary"
);
}
#[test]
fn ignores_a_directory_named_like_the_manifest() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join("harn.toml")).unwrap();
assert_eq!(find_nearest_manifest(&root), None);
}
#[test]
fn yields_none_when_absent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
std::fs::create_dir_all(root.join(".git")).unwrap();
let nested = root.join("a");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(find_nearest_manifest(&nested), None);
}
#[test]
fn arbitrary_sentinel_filename_parameterizes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
touch(&root.join(".harn").join("package-current.toml"));
let nested = root.join("pkg").join("src");
std::fs::create_dir_all(&nested).unwrap();
let found = find_nearest_ancestor(&nested, ".harn/package-current.toml")
.expect("multi-component sentinel resolves");
assert_eq!(found.dir, root);
}
}