use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AnalysisRoot {
pub dir: PathBuf,
pub exclude: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Fleet {
repositories: Vec<PathBuf>,
}
fn canonical(p: &Path) -> Result<PathBuf, String> {
crate::platform::canonicalize(p).map_err(|e| format!("{}: {e}", p.display()))
}
impl Fleet {
pub fn discover(root: &Path) -> Result<Self, String> {
let tree = crate::fleet::git::SubmoduleTree::discover_allow_empty(root).map_err(|e| e.to_string())?;
let mut repositories = vec![canonical(root)?];
for node in tree.top_down().iter().filter(|n| n.initialized) {
repositories.push(canonical(&node.abs_path)?);
}
Ok(Self { repositories })
}
pub fn of(repositories: Vec<PathBuf>) -> Result<Self, String> {
Ok(Self { repositories: repositories.iter().map(|p| canonical(p)).collect::<Result<_, _>>()? })
}
pub fn repositories(&self) -> &[PathBuf] {
&self.repositories
}
pub fn repository_of(&self, file: &Path) -> Option<&Path> {
self.repositories.iter().filter(|r| file.starts_with(r)).max_by_key(|r| r.components().count()).map(PathBuf::as_path)
}
fn nested_in(&self, repo: &Path) -> Vec<&Path> {
self.repositories.iter().filter(|r| r.as_path() != repo && r.starts_with(repo)).map(PathBuf::as_path).collect()
}
pub fn analysis_root(&self, file: &Path) -> Result<AnalysisRoot, String> {
let repo = self
.repository_of(file)
.ok_or_else(|| format!("{} is in no repository of this worktree", file.display()))?;
let below = file.parent().and_then(|p| p.strip_prefix(repo).ok()).unwrap_or(Path::new(""));
let mut dir = repo.to_path_buf();
for part in below.components() {
dir.push(part);
if dir.join("Cargo.toml").is_file() {
return Ok(AnalysisRoot { dir, exclude: Vec::new() });
}
}
self.repository_root(repo)
}
fn repository_roots(&self, repo: &Path) -> Result<(AnalysisRoot, Vec<PathBuf>), String> {
if repo.join("Cargo.toml").is_file() {
return Ok((AnalysisRoot { dir: repo.to_owned(), exclude: Vec::new() }, Vec::new()));
}
let below = cargo_dirs(repo, &self.nested_in(repo))?;
let exclude = below
.iter()
.map(|d| format!("{}/**", d.strip_prefix(repo).unwrap_or(d).to_string_lossy().replace('\\', "/")))
.collect();
Ok((AnalysisRoot { dir: repo.to_owned(), exclude }, below))
}
fn repository_root(&self, repo: &Path) -> Result<AnalysisRoot, String> {
Ok(self.repository_roots(repo)?.0)
}
pub fn analysis_roots(&self, only: Option<&Path>) -> Result<Vec<AnalysisRoot>, String> {
let mut roots = Vec::new();
for repo in &self.repositories {
if only.is_some_and(|o| o != repo.as_path()) {
continue;
}
let (own, below) = self.repository_roots(repo)?;
roots.extend(below.into_iter().map(|dir| AnalysisRoot { dir, exclude: Vec::new() }));
roots.push(own);
}
roots.sort();
Ok(roots)
}
}
fn cargo_dirs(repo: &Path, nested: &[&Path]) -> Result<Vec<PathBuf>, String> {
let nested: Vec<PathBuf> = nested.iter().map(|p| p.to_path_buf()).collect();
let mut found: Vec<PathBuf> = Vec::new();
let walker = ignore::WalkBuilder::new(repo)
.hidden(false)
.git_ignore(true)
.git_exclude(true)
.require_git(false)
.filter_entry(move |e| {
let p = e.path();
e.file_name() != ".git" && !nested.iter().any(|n| p == n.as_path())
})
.build();
for entry in walker {
let entry = entry.map_err(|e| e.to_string())?;
if entry.file_name() != "Cargo.toml" || !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Some(dir) = entry.path().parent() else { continue };
if dir == repo || found.iter().any(|f| dir.starts_with(f)) {
continue;
}
found.retain(|f| !f.starts_with(dir));
found.push(dir.to_path_buf());
}
found.sort();
Ok(found)
}
#[cfg(test)]
#[path = "roots_tests.rs"]
mod tests;