use std::path::{Path, PathBuf};
pub const FILE_MARKERS: &[&str] = &[
"Cargo.toml",
"flake.nix",
"package.json",
"go.mod",
"pyproject.toml",
];
pub const GIT_MARKER: &str = ".git";
pub fn project_root_with<F>(cwd: &Path, has_marker: F) -> PathBuf
where
F: Fn(&Path, &str) -> bool,
{
let mut dir = cwd;
loop {
if has_marker(dir, GIT_MARKER) {
return dir.to_path_buf();
}
if FILE_MARKERS.iter().any(|m| has_marker(dir, m)) {
return dir.to_path_buf();
}
match dir.parent() {
Some(parent) => dir = parent,
None => return cwd.to_path_buf(),
}
}
}
#[must_use]
pub fn project_root(cwd: &Path) -> PathBuf {
project_root_with(cwd, |dir, marker| dir.join(marker).exists())
}
pub fn find_project_root_with<F>(cwd: &Path, has_marker: F) -> Option<PathBuf>
where
F: Fn(&Path, &str) -> bool,
{
let mut dir = cwd;
loop {
if has_marker(dir, GIT_MARKER) || FILE_MARKERS.iter().any(|m| has_marker(dir, m)) {
return Some(dir.to_path_buf());
}
dir = dir.parent()?;
}
}
#[must_use]
pub fn find_project_root(cwd: &Path) -> Option<PathBuf> {
find_project_root_with(cwd, |dir, marker| dir.join(marker).exists())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::path::Path;
fn mock(present: &[&str]) -> impl Fn(&Path, &str) -> bool + use<> {
let set: HashSet<String> = present.iter().map(|s| (*s).to_string()).collect();
move |dir: &Path, marker: &str| set.contains(&format!("{}::{}", dir.display(), marker))
}
#[test]
fn git_at_cwd_returns_cwd() {
let cwd = Path::new("/home/u/code/repo");
let has = mock(&["/home/u/code/repo::.git"]);
assert_eq!(project_root_with(cwd, has), PathBuf::from("/home/u/code/repo"));
}
#[test]
fn walks_up_to_git_ancestor() {
let cwd = Path::new("/home/u/code/repo/src/deep/nested");
let has = mock(&["/home/u/code/repo::.git"]);
assert_eq!(project_root_with(cwd, has), PathBuf::from("/home/u/code/repo"));
}
#[test]
fn git_outranks_inner_cargo_toml() {
let cwd = Path::new("/repo/crates/inner");
let has = mock(&["/repo/crates/inner::Cargo.toml", "/repo::.git"]);
assert_eq!(project_root_with(cwd, has), PathBuf::from("/repo/crates/inner"));
}
#[test]
fn git_at_same_level_beats_file_marker() {
let cwd = Path::new("/repo");
let has = mock(&["/repo::.git", "/repo::Cargo.toml"]);
assert_eq!(project_root_with(cwd, has), PathBuf::from("/repo"));
}
#[test]
fn file_marker_ancestor_when_no_git() {
let cwd = Path::new("/proj/sub/dir");
let has = mock(&["/proj::flake.nix"]);
assert_eq!(project_root_with(cwd, has), PathBuf::from("/proj"));
}
#[test]
fn no_marker_anywhere_returns_cwd() {
let cwd = Path::new("/tmp/scratch/x");
let has = mock(&[]);
assert_eq!(project_root_with(cwd, has), PathBuf::from("/tmp/scratch/x"));
}
#[test]
fn nearest_marker_wins_over_higher_one() {
let cwd = Path::new("/a/b/c");
let has = mock(&["/a/b::package.json", "/a::flake.nix"]);
assert_eq!(project_root_with(cwd, has), PathBuf::from("/a/b"));
}
#[test]
fn each_file_marker_is_recognized() {
for m in FILE_MARKERS {
let cwd = Path::new("/x/y");
let has = mock(&[&format!("/x::{m}")]);
assert_eq!(
project_root_with(cwd, has),
PathBuf::from("/x"),
"marker {m} should be recognized",
);
}
}
#[test]
fn find_project_root_distinguishes_project_from_scratch() {
assert!(find_project_root_with(Path::new("/a/b/c"), |_, _| false).is_none());
let at_a = find_project_root_with(Path::new("/a/b/c"), |d, _| d == Path::new("/a"));
assert_eq!(at_a, Some(PathBuf::from("/a")));
}
}