use std::path::{Path, PathBuf};
#[derive(Clone, Copy)]
pub enum Marker {
SearchIndex,
#[allow(dead_code)]
DepsCache,
#[allow(dead_code)]
Any,
}
impl Marker {
fn matches(self, candidate: &Path) -> bool {
let dir = candidate.join(".ast-bro");
if !dir.is_dir() {
return false;
}
match self {
Marker::SearchIndex => dir.join("index").join("meta.json").is_file(),
Marker::DepsCache => dir.join("deps").join("graph.bin").is_file(),
Marker::Any => true,
}
}
}
pub fn resolve_home(path_arg: &Path, cwd: &Path, marker: Marker) -> (PathBuf, bool) {
let abs_path = canonicalize_lenient(path_arg);
let abs_cwd = canonicalize_lenient(cwd);
if !abs_path.starts_with(&abs_cwd) {
return (abs_path, false);
}
let start_dir: PathBuf = if abs_path.is_dir() {
abs_path.clone()
} else {
abs_path.parent().map(Path::to_path_buf).unwrap_or(abs_path.clone())
};
let mut cur = start_dir.as_path();
loop {
if marker.matches(cur) {
return (cur.to_path_buf(), true);
}
if cur == abs_cwd {
break;
}
match cur.parent() {
Some(p) if p.starts_with(&abs_cwd) || p == abs_cwd => cur = p,
_ => break,
}
}
(abs_cwd, false)
}
pub fn find_root_for(file: &Path) -> Result<PathBuf, String> {
if !file.exists() {
return Err(format!("file not found: {}", file.display()));
}
let abs = file
.canonicalize()
.map_err(|e| format!("cannot resolve {}: {}", file.display(), e))?;
let mut cur: &Path = if abs.is_dir() {
&abs
} else {
abs.parent().ok_or("no parent directory")?
};
let manifest_names = [
"Cargo.toml",
"go.mod",
"package.json",
"pyproject.toml",
"build.gradle",
"build.gradle.kts",
"build.sbt",
"pom.xml",
];
let home = home_dir();
let mut nearest_manifest: Option<PathBuf> = None;
loop {
if nearest_manifest.is_some()
&& home.as_deref().is_some_and(|h| h.starts_with(cur))
{
break;
}
if cur.join(".git").exists() {
return Ok(cur.to_path_buf());
}
if nearest_manifest.is_none() {
for n in &manifest_names {
if cur.join(n).is_file() {
nearest_manifest = Some(cur.to_path_buf());
break;
}
}
}
match cur.parent() {
Some(p) => cur = p,
None => break,
}
}
if let Some(root) = nearest_manifest {
return Ok(root);
}
Ok(if abs.is_dir() {
abs
} else {
abs.parent()
.ok_or("no parent directory")?
.to_path_buf()
})
}
fn home_dir() -> Option<PathBuf> {
static HOME: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
HOME.get_or_init(|| {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.and_then(|p| p.canonicalize().ok())
})
.clone()
}
fn canonicalize_lenient(p: &Path) -> PathBuf {
if let Ok(c) = p.canonicalize() {
return c;
}
let abs: PathBuf = if p.is_absolute() {
p.to_path_buf()
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(p)
} else {
return p.to_path_buf();
};
let mut tail: Vec<&std::ffi::OsStr> = Vec::new();
let mut cur = abs.as_path();
loop {
match cur.canonicalize() {
Ok(c) => {
let mut out = c;
for seg in tail.into_iter().rev() {
out.push(seg);
}
return out;
}
Err(_) => match (cur.file_name(), cur.parent()) {
(Some(name), Some(parent)) => {
tail.push(name);
cur = parent;
}
_ => return abs,
},
}
}
}
pub fn relative_posix(path: &Path, home: &Path) -> Option<String> {
let abs_path = canonicalize_lenient(path);
let abs_home = canonicalize_lenient(home);
let rel = abs_path.strip_prefix(&abs_home).ok()?;
let s = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
Some(s)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CorpusRel {
Subset,
Superset,
Sibling { common: String },
}
pub fn compare_corpus(recorded: &str, requested: &str) -> CorpusRel {
let r_parts: Vec<&str> = if recorded.is_empty() {
Vec::new()
} else {
recorded.split('/').collect()
};
let q_parts: Vec<&str> = if requested.is_empty() {
Vec::new()
} else {
requested.split('/').collect()
};
if r_parts.is_empty() {
return CorpusRel::Subset;
}
if q_parts.is_empty() {
return CorpusRel::Superset;
}
if q_parts.len() >= r_parts.len() && q_parts[..r_parts.len()] == r_parts[..] {
return CorpusRel::Subset;
}
if r_parts.len() >= q_parts.len() && r_parts[..q_parts.len()] == q_parts[..] {
return CorpusRel::Superset;
}
let common_len = r_parts
.iter()
.zip(q_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let common = r_parts[..common_len].join("/");
CorpusRel::Sibling { common }
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn touch(p: &Path) {
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(p, "").unwrap();
}
#[test]
fn find_root_stops_at_git_boundary_above_member_manifest() {
let tmp = tempdir().unwrap();
let repo = tmp.path().join("mono");
fs::create_dir_all(repo.join(".git")).unwrap();
touch(&repo.join("pkg/Cargo.toml"));
touch(&repo.join("pkg/src/lib.rs"));
let root = find_root_for(&repo.join("pkg/src/lib.rs")).unwrap();
assert_eq!(root, repo.canonicalize().unwrap());
}
#[test]
fn find_root_uses_nearest_manifest_without_git() {
let tmp = tempdir().unwrap();
let proj = tmp.path().join("proj");
touch(&proj.join("Cargo.toml"));
touch(&proj.join("src/lib.rs"));
let root = find_root_for(&proj.join("src/lib.rs")).unwrap();
assert_eq!(root, proj.canonicalize().unwrap());
}
#[test]
fn find_root_ignores_stray_outer_manifest() {
let tmp = tempdir().unwrap();
touch(&tmp.path().join("package.json"));
let proj = tmp.path().join("code/proj");
touch(&proj.join("Cargo.toml"));
touch(&proj.join("src/lib.rs"));
let root = find_root_for(&proj.join("src/lib.rs")).unwrap();
assert_eq!(root, proj.canonicalize().unwrap());
}
#[test]
fn find_root_falls_back_to_parent_dir() {
let tmp = tempdir().unwrap();
let dir = tmp.path().join("loose");
touch(&dir.join("script.py"));
let root = find_root_for(&dir.join("script.py")).unwrap();
assert_eq!(root, dir.canonicalize().unwrap());
}
#[test]
fn find_root_inner_git_wins_over_outer_git() {
let tmp = tempdir().unwrap();
let outer = tmp.path().join("outer");
fs::create_dir_all(outer.join(".git")).unwrap();
let inner = outer.join("vendor/dep");
fs::create_dir_all(inner.join(".git")).unwrap();
touch(&inner.join("src/main.rs"));
let root = find_root_for(&inner.join("src/main.rs")).unwrap();
assert_eq!(root, inner.canonicalize().unwrap());
}
#[test]
fn compare_corpus_subset_explicit() {
assert_eq!(
compare_corpus("packages", "packages/a"),
CorpusRel::Subset
);
assert_eq!(compare_corpus("packages", "packages"), CorpusRel::Subset);
}
#[test]
fn compare_corpus_subset_when_recorded_whole_home() {
assert_eq!(compare_corpus("", "packages"), CorpusRel::Subset);
assert_eq!(compare_corpus("", ""), CorpusRel::Subset);
}
#[test]
fn compare_corpus_superset() {
assert_eq!(
compare_corpus("packages/a", "packages"),
CorpusRel::Superset
);
assert_eq!(compare_corpus("packages", ""), CorpusRel::Superset);
}
#[test]
fn compare_corpus_sibling_with_common_ancestor() {
assert_eq!(
compare_corpus("packages/a", "packages/b"),
CorpusRel::Sibling {
common: "packages".to_string()
}
);
}
#[test]
fn compare_corpus_sibling_no_common_ancestor() {
assert_eq!(
compare_corpus("packages", "src"),
CorpusRel::Sibling {
common: String::new()
}
);
}
#[test]
fn resolve_home_finds_existing_above() {
let dir = tempdir().unwrap();
let cwd = dir.path();
fs::create_dir_all(cwd.join(".ast-bro").join("index")).unwrap();
fs::write(
cwd.join(".ast-bro").join("index").join("meta.json"),
"{}",
)
.unwrap();
fs::create_dir_all(cwd.join("packages").join("xyz")).unwrap();
let (home, found) = resolve_home(
&cwd.join("packages").join("xyz"),
cwd,
Marker::SearchIndex,
);
assert!(found);
assert_eq!(
home.canonicalize().unwrap(),
cwd.canonicalize().unwrap()
);
}
#[test]
fn resolve_home_returns_cwd_when_no_existing() {
let dir = tempdir().unwrap();
let cwd = dir.path();
fs::create_dir_all(cwd.join("packages").join("xyz")).unwrap();
let (home, found) =
resolve_home(&cwd.join("packages").join("xyz"), cwd, Marker::SearchIndex);
assert!(!found);
assert_eq!(
home.canonicalize().unwrap(),
cwd.canonicalize().unwrap()
);
}
#[test]
fn resolve_home_caps_at_cwd_does_not_escape() {
let dir = tempdir().unwrap();
let outer = dir.path();
fs::create_dir_all(outer.join(".ast-bro").join("index")).unwrap();
fs::write(
outer.join(".ast-bro").join("index").join("meta.json"),
"{}",
)
.unwrap();
let inner = outer.join("inner");
fs::create_dir_all(inner.join("sub")).unwrap();
let (home, found) =
resolve_home(&inner.join("sub"), &inner, Marker::SearchIndex);
assert!(!found);
assert_eq!(
home.canonicalize().unwrap(),
inner.canonicalize().unwrap()
);
}
#[test]
fn resolve_home_path_outside_cwd_uses_path() {
let outer = tempdir().unwrap();
let cwd_dir = tempdir().unwrap();
let cwd = cwd_dir.path();
fs::create_dir_all(outer.path().join("a")).unwrap();
let (home, found) =
resolve_home(&outer.path().join("a"), cwd, Marker::SearchIndex);
assert!(!found);
assert_eq!(
home.canonicalize().unwrap(),
outer.path().join("a").canonicalize().unwrap()
);
}
#[test]
fn relative_posix_basic() {
let dir = tempdir().unwrap();
let home = dir.path();
fs::create_dir_all(home.join("a").join("b")).unwrap();
assert_eq!(
relative_posix(&home.join("a").join("b"), home),
Some("a/b".to_string())
);
assert_eq!(relative_posix(home, home), Some(String::new()));
}
}