use std::path::{Path, PathBuf};
use crate::config::DiscoveryConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Walk {
pub dirs: Vec<PathBuf>,
pub stopped: StopReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
FilesystemRoot,
Home,
GitRoot,
MaxDepth,
WalkUpDisabled,
}
impl StopReason {
pub fn describe(self, max_depth: usize) -> String {
match self {
StopReason::FilesystemRoot => "reached the filesystem root".into(),
StopReason::Home => "reached $HOME".into(),
StopReason::GitRoot => "reached .git".into(),
StopReason::MaxDepth => format!("reached the max_depth limit ({})", dirs(max_depth)),
StopReason::WalkUpDisabled => "--no-walk-up / [discovery] walk_up = false".into(),
}
}
}
pub(crate) fn dirs(n: usize) -> String {
format!("{n} director{}", if n == 1 { "y" } else { "ies" })
}
pub fn walk(start: &Path, cfg: &DiscoveryConfig) -> Walk {
let start = normalize(start);
let mut dirs = vec![start.clone()];
if !cfg.walk_up {
return Walk {
dirs,
stopped: StopReason::WalkUpDisabled,
};
}
let home = directories::UserDirs::new().map(|d| normalize(d.home_dir()));
let mut current = start;
let stopped = loop {
if dirs.len() >= cfg.max_depth {
break StopReason::MaxDepth;
}
if cfg.stop_at_git && current.join(".git").exists() {
break StopReason::GitRoot;
}
if Some(¤t) == home.as_ref() {
break StopReason::Home;
}
match current.parent() {
Some(parent) if parent != current => {
current = parent.to_path_buf();
dirs.push(current.clone());
}
_ => break StopReason::FilesystemRoot,
}
};
Walk { dirs, stopped }
}
pub fn find_project_root(
start: &Path,
cfg: &DiscoveryConfig,
is_root: impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
walk(start, cfg).dirs.into_iter().find(|d| is_root(d))
}
pub fn collect_config_paths(start: &Path, cfg: &DiscoveryConfig) -> Vec<PathBuf> {
walk(start, cfg)
.dirs
.into_iter()
.map(|d| d.join(".pmpx.toml"))
.filter(|p| p.is_file())
.collect()
}
fn normalize(p: &Path) -> PathBuf {
let absolute = if p.is_absolute() {
p.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(p))
.unwrap_or_else(|_| p.to_path_buf())
};
lexical_normalize(&absolute)
}
fn lexical_normalize(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for comp in p.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> DiscoveryConfig {
DiscoveryConfig::default()
}
fn cfg_at_most(n: usize) -> DiscoveryConfig {
DiscoveryConfig {
max_depth: n,
..DiscoveryConfig::default()
}
}
fn tree(dirs: &[&str]) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
for d in dirs {
std::fs::create_dir_all(tmp.path().join(d)).unwrap();
}
tmp
}
fn touch(path: &Path) {
if let Some(p) = path.parent() {
std::fs::create_dir_all(p).unwrap();
}
std::fs::write(path, "").unwrap();
}
#[test]
fn starts_with_the_start_directory() {
let tmp = tree(&["a/b/c"]);
let w = walk(&tmp.path().join("a/b/c"), &cfg());
assert_eq!(w.dirs[0], normalize(&tmp.path().join("a/b/c")));
}
#[test]
fn walks_up_in_order_from_near_to_far() {
let tmp = tree(&["a/b/c"]);
let w = walk(&tmp.path().join("a/b/c"), &cfg());
assert!(w.dirs[0].ends_with("c"), "{:?}", w.dirs[0]);
assert!(w.dirs[1].ends_with("b"), "{:?}", w.dirs[1]);
assert!(w.dirs[2].ends_with("a"), "{:?}", w.dirs[2]);
for pair in w.dirs.windows(2) {
assert_eq!(
pair[1].as_path(),
pair[0].parent().unwrap(),
"{} should walk up to its parent directory",
pair[0].display()
);
}
assert!(w.dirs.len() <= cfg().max_depth);
assert!(
matches!(
w.stopped,
StopReason::MaxDepth
| StopReason::FilesystemRoot
| StopReason::Home
| StopReason::GitRoot
),
"unexpectedly stopped at {:?}",
w.stopped
);
}
#[test]
fn a_generous_max_depth_still_terminates() {
let tmp = tree(&["a/b/c"]);
let mut c = cfg();
c.max_depth = 4096;
let w = walk(&tmp.path().join("a/b/c"), &c);
assert!(
matches!(w.stopped, StopReason::Home | StopReason::FilesystemRoot),
"unexpectedly stopped at {:?}",
w.stopped
);
assert!(
w.dirs.len() < 4096,
"should not actually use up the whole limit"
);
}
#[test]
fn walk_up_disabled_returns_only_the_start() {
let tmp = tree(&["a/b/c"]);
let start = tmp.path().join("a/b/c");
let mut c = cfg();
c.walk_up = false;
let w = walk(&start, &c);
assert_eq!(w.dirs, vec![normalize(&start)]);
assert_eq!(w.stopped, StopReason::WalkUpDisabled);
}
#[test]
fn max_depth_limits_how_many_directories_are_checked() {
let tmp = tree(&["a/b/c/d/e"]);
let w = walk(&tmp.path().join("a/b/c/d/e"), &cfg_at_most(3));
assert_eq!(w.dirs.len(), 3);
assert_eq!(w.stopped, StopReason::MaxDepth);
assert_eq!(
w.stopped.describe(3),
"reached the max_depth limit (3 directories)"
);
}
#[test]
fn a_single_directory_is_not_plural() {
assert_eq!(dirs(1), "1 directory");
assert_eq!(dirs(0), "0 directories");
assert_eq!(dirs(6), "6 directories");
assert_eq!(
StopReason::MaxDepth.describe(1),
"reached the max_depth limit (1 directory)"
);
}
#[test]
fn stops_after_a_directory_containing_git() {
let tmp = tree(&["repo/web/src"]);
std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();
let w = walk(&tmp.path().join("repo/web/src"), &cfg());
assert_eq!(w.stopped, StopReason::GitRoot);
assert!(w.dirs.last().unwrap().ends_with("repo"));
let above_tmp = normalize(tmp.path());
let above_tmp = above_tmp.parent().unwrap();
assert!(!w.dirs.iter().any(|d| d.as_path() == above_tmp));
}
#[test]
fn stop_at_git_can_be_turned_off() {
let tmp = tree(&["repo/web/src"]);
std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();
let mut c = cfg();
c.stop_at_git = false;
let w = walk(&tmp.path().join("repo/web/src"), &c);
assert!(w.dirs.len() > 3);
assert_ne!(w.stopped, StopReason::GitRoot);
}
#[test]
fn git_file_also_stops_the_walk() {
let tmp = tree(&["repo/web/src"]);
touch(&tmp.path().join("repo/.git"));
let w = walk(&tmp.path().join("repo/web/src"), &cfg());
assert_eq!(w.stopped, StopReason::GitRoot);
}
#[test]
fn find_project_root_picks_the_nearest_hit() {
let tmp = tree(&["repo/web/src"]);
touch(&tmp.path().join("repo/Cargo.toml"));
touch(&tmp.path().join("repo/web/package.json"));
let root = find_project_root(&tmp.path().join("repo/web/src"), &cfg(), |d| {
d.join("Cargo.toml").exists() || d.join("package.json").exists()
})
.unwrap();
assert!(
root.ends_with("web"),
"the root should be web, got {root:?}"
);
}
#[test]
fn find_project_root_returns_none_when_nothing_matches() {
let tmp = tree(&["repo/some/dir"]);
std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();
let root = find_project_root(&tmp.path().join("repo/some/dir"), &cfg(), |d| {
d.join("Cargo.toml").exists()
});
assert!(root.is_none());
}
#[test]
fn the_start_directory_itself_can_be_the_root() {
let tmp = tree(&["proj"]);
touch(&tmp.path().join("proj/Cargo.toml"));
let root = find_project_root(&tmp.path().join("proj"), &cfg(), |d| {
d.join("Cargo.toml").exists()
})
.unwrap();
assert_eq!(root, normalize(&tmp.path().join("proj")));
}
#[test]
fn config_collection_reaches_above_the_project_root() {
let tmp = tree(&["repo/crates/core/src"]);
std::fs::create_dir_all(tmp.path().join("repo/.git")).unwrap();
touch(&tmp.path().join("repo/.pmpx.toml"));
touch(&tmp.path().join("repo/crates/core/.pmpx.toml"));
touch(&tmp.path().join("repo/crates/core/Cargo.toml"));
let start = tmp.path().join("repo/crates/core/src");
let root = find_project_root(&start, &cfg(), |d| d.join("Cargo.toml").exists()).unwrap();
assert!(root.ends_with("core"));
let cfgs = collect_config_paths(&start, &cfg());
assert_eq!(cfgs.len(), 2, "{cfgs:?}");
assert!(cfgs[0].ends_with("core/.pmpx.toml"));
assert!(cfgs[1].ends_with("repo/.pmpx.toml"));
assert!(
cfgs.iter().any(|p| p.ends_with("repo/.pmpx.toml")),
"the config one layer above the project root must be visible"
);
}
#[test]
fn config_collection_skips_directories_without_a_config() {
let tmp = tree(&["repo/a/b/c"]);
touch(&tmp.path().join("repo/.pmpx.toml"));
let cfgs = collect_config_paths(&tmp.path().join("repo/a/b/c"), &cfg());
assert_eq!(cfgs.len(), 1);
assert!(cfgs[0].ends_with("repo/.pmpx.toml"));
}
#[test]
fn walking_from_a_relative_path_works() {
let w = walk(Path::new("."), &cfg());
assert!(w.dirs[0].is_absolute(), "{:?}", w.dirs[0]);
assert_eq!(w.dirs[0], normalize(&std::env::current_dir().unwrap()));
}
#[test]
fn lexical_normalize_removes_dots_without_touching_the_fs() {
assert_eq!(
lexical_normalize(Path::new("/a/b/../c")),
PathBuf::from("/a/c")
);
assert_eq!(
lexical_normalize(Path::new("/a/./b")),
PathBuf::from("/a/b")
);
assert_eq!(
lexical_normalize(Path::new("/a/b/../..")),
PathBuf::from("/")
);
}
#[test]
fn every_stop_reason_has_a_description() {
for (reason, max) in [
(StopReason::FilesystemRoot, 8),
(StopReason::Home, 8),
(StopReason::GitRoot, 8),
(StopReason::MaxDepth, 8),
(StopReason::WalkUpDisabled, 8),
] {
assert!(!reason.describe(max).is_empty());
}
}
}