use std::path::{Path, PathBuf};
const MAX_DEPTH: usize = 100;
const PROJECT_MARKERS: &[&str] = &[".git", ".rumdl.toml", "pyproject.toml", ".markdownlint.json"];
pub fn discover_project_root_from(start_dir: &Path) -> PathBuf {
let absolute_start = if start_dir.is_relative() {
std::env::current_dir().map_or_else(|_| start_dir.to_path_buf(), |cwd| cwd.join(start_dir))
} else {
start_dir.to_path_buf()
};
let mut current = absolute_start.clone();
for _ in 0..MAX_DEPTH {
if PROJECT_MARKERS.iter().any(|marker| current.join(marker).exists()) {
return canonicalize_or_keep(current);
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => break,
}
}
canonicalize_or_keep(absolute_start)
}
fn canonicalize_or_keep(path: PathBuf) -> PathBuf {
path.canonicalize().unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_discovers_root_via_git_marker() {
let temp = tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
let nested = root.join("a").join("b").join("c");
fs::create_dir_all(&nested).unwrap();
assert_eq!(discover_project_root_from(&nested), root);
}
#[test]
fn test_discovers_root_via_rumdl_toml_marker() {
let temp = tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
fs::write(root.join(".rumdl.toml"), "").unwrap();
let nested = root.join("docs");
fs::create_dir_all(&nested).unwrap();
assert_eq!(discover_project_root_from(&nested), root);
}
#[test]
fn test_discovers_root_via_pyproject_toml_marker() {
let temp = tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
fs::write(root.join("pyproject.toml"), "").unwrap();
let nested = root.join("src");
fs::create_dir_all(&nested).unwrap();
assert_eq!(discover_project_root_from(&nested), root);
}
#[test]
fn test_marker_at_ancestor_wins_over_deeper_start() {
let temp = tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
fs::write(root.join(".git"), "stub").unwrap();
let deeply_nested = root.join("a").join("b").join("c").join("d");
fs::create_dir_all(&deeply_nested).unwrap();
assert_eq!(discover_project_root_from(&deeply_nested), root);
}
#[test]
fn test_first_marker_wins_when_nested_projects() {
let temp = tempdir().unwrap();
let outer = temp.path().canonicalize().unwrap();
fs::write(outer.join(".git"), "stub").unwrap();
let inner = outer.join("subproject");
fs::create_dir_all(&inner).unwrap();
fs::write(inner.join(".rumdl.toml"), "").unwrap();
let start = inner.join("docs");
fs::create_dir_all(&start).unwrap();
assert_eq!(discover_project_root_from(&start), inner, "closest marker should win");
}
#[test]
fn test_canonicalizes_symlinked_root() {
let temp = tempdir().unwrap();
let real_root = temp.path().canonicalize().unwrap().join("real");
fs::create_dir_all(&real_root).unwrap();
fs::create_dir_all(real_root.join(".git")).unwrap();
let link = temp.path().canonicalize().unwrap().join("link");
if std::os::unix::fs::symlink(&real_root, &link).is_err() {
return;
}
let from_link = discover_project_root_from(&link);
assert_eq!(from_link, real_root, "symlink should canonicalize to real path");
}
}