wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Which directory a file's analysis is made at.
//!
//! omnicode analyzes a directory as one repository: it leaves submodules out, and analyzes
//! Rust with its compiler's understanding only when a Cargo workspace is at that directory's
//! root. A worktree is a fleet of repositories, and a repository may keep its Cargo workspace
//! below its root (`wsb/` in a repository that also holds docs and scripts). So the unit of
//! analysis is not the worktree but the *analysis root*:
//!
//! - a file's repository is the innermost repository of the fleet that holds it;
//! - its analysis root is the shallowest directory between that repository and the file that
//!   holds a `Cargo.toml`, else the repository itself;
//! - an analysis at a repository's root leaves out the Cargo workspaces below it, which are
//!   analysis roots of their own, so no file is analyzed twice.

use std::path::{Path, PathBuf};

/// A directory analyzed as one, and the paths below it its analysis leaves out.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AnalysisRoot {
    /// Canonical.
    pub dir: PathBuf,
    /// Globs relative to `dir`, as `omni index --exclude` takes them.
    pub exclude: Vec<String>,
}

/// The repositories of a worktree.
#[derive(Debug, Clone)]
pub struct Fleet {
    /// Canonical; the worktree's own repository first.
    repositories: Vec<PathBuf>,
}

fn canonical(p: &Path) -> Result<PathBuf, String> {
    crate::platform::canonicalize(p).map_err(|e| format!("{}: {e}", p.display()))
}

impl Fleet {
    /// The worktree at `root` and every submodule of it that is checked out.
    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 })
    }

    /// A fleet of exactly these repositories, for callers that already know them.
    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
    }

    /// The innermost repository holding `file` (absolute, canonical).
    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)
    }

    /// The repositories nested inside `repo`.
    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()
    }

    /// Where the analysis of `file` (absolute, canonical) is made.
    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)
    }

    /// The analysis at a repository's own root, and the Cargo workspaces below it that are
    /// analysis roots of their own (none when the repository is one).
    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)
    }

    /// Every analysis root of the fleet's repositories (or of one of them): each repository's
    /// own, and the Cargo workspaces below one without a Cargo workspace at its root.
    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)
    }
}

/// The shallowest directories below `repo` (not `repo` itself) that hold a `Cargo.toml`, as
/// git sees the tree: ignored directories are not looked into, nor are nested repositories.
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;