root_scan.rs raw

use std::path::PathBuf;

use anyhow::Result;
use walkdir::WalkDir;

use super::metadata::resolve_packages;
use super::{Project, ProjectSource, SourceKind};
use crate::fsutil::is_cargo_build_output;

/// Directory names never worth descending into while looking for manifests.
///
/// A `.git` directory holds thousands of objects and cannot contain a cargo
/// project. Nothing else is pruned by name — including dotted directories,
/// since projects do turn up inside them.
const PRUNED: [&str; 1] = [".git"];

/// Projects found by walking directories for cargo manifests.
///
/// Like the other two sources, this collects package directories — those
/// holding a `Cargo.toml` — and leaves it to [`resolve_packages`] to ask cargo
/// where each one's workspace root and target directory really are. That keeps
/// a single, authoritative answer to "where does the build output go?" shared
/// across every source, rather than a second-guess special to the scan.
///
/// The walk prunes in two places. It stops descending the instant it finds a
/// manifest: a workspace root sits above its members, so once the root is
/// found there is nothing deeper worth looking at — cargo resolves the members
/// from the root. And it never enters cargo build output, which is regenerable
/// rather than source and can contain manifests of its own (checked-out
/// build-script fixtures) that are not projects a person manages.
///
/// Because it keys on manifests rather than build output, it finds projects
/// that have never been built or whose target directory has already been
/// cleaned — the ones a `target/`-based scan cannot see.
pub struct RootScan {
    roots: Vec<PathBuf>,
}

impl RootScan {
    pub fn new(roots: Vec<PathBuf>) -> Self {
        Self { roots }
    }

    /// Every directory under the roots that holds a `Cargo.toml`.
    ///
    /// The walk stops at the first manifest on any path and never descends into
    /// build output, so this yields workspace roots and standalone packages,
    /// not the members nested beneath them.
    fn package_dirs(&self, warnings: &mut Vec<String>) -> Vec<PathBuf> {
        let mut dirs = Vec::new();

        for root in &self.roots {
            if !root.is_dir() {
                warnings.push(format!(
                    "root-scan: {} is not a directory; skipping",
                    root.display()
                ));
                continue;
            }

            let mut walk = WalkDir::new(root).into_iter();
            loop {
                let entry = match walk.next() {
                    None => break,
                    // An unreadable directory is one we could not clean either;
                    // skip it and keep walking the rest of the tree.
                    Some(Err(_)) => continue,
                    Some(Ok(entry)) => entry,
                };

                if !entry.file_type().is_dir() {
                    continue;
                }

                // Never worth descending into: a `.git` object store, or cargo
                // build output. That output is regenerable, not source, and can
                // hold manifests of its own (checked-out fixtures) that are not
                // projects — so a manifest found inside it would be a false
                // positive.
                if PRUNED.iter().any(|name| entry.file_name() == *name)
                    || is_cargo_build_output(entry.path())
                {
                    walk.skip_current_dir();
                    continue;
                }

                if entry.path().join("Cargo.toml").is_file() {
                    dirs.push(entry.path().to_path_buf());
                    // A manifest here means this is a package or a workspace
                    // root; its members, if any, sit below and cargo finds them
                    // from here, so there is no need to look deeper.
                    walk.skip_current_dir();
                }
            }
        }

        dirs
    }
}

impl ProjectSource for RootScan {
    fn kind(&self) -> SourceKind {
        SourceKind::RootScan
    }

    fn projects(&self, warnings: &mut Vec<String>) -> Result<Vec<Project>> {
        Ok(resolve_packages(
            self.kind(),
            self.package_dirs(warnings),
            warnings,
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    use std::fs;
    use std::path::Path;

    use tempfile::TempDir;

    fn cargo_project(root: &Path, name: &str) -> PathBuf {
        let project = root.join(name);
        fs::create_dir_all(&project).unwrap();
        fs::write(project.join("Cargo.toml"), "[package]\n").unwrap();
        project
    }

    fn built_target(project: &Path) -> PathBuf {
        let target = project.join("target");
        fs::create_dir_all(target.join("debug")).unwrap();
        fs::write(
            target.join("CACHEDIR.TAG"),
            "Signature: 8a477f597d28d172789f06886806bc55\n\
             # This file is a cache directory tag created by cargo.\n",
        )
        .unwrap();
        target
    }

    /// The package directories found under `roots`, sorted for stable asserts.
    fn package_dirs(roots: Vec<PathBuf>) -> Vec<PathBuf> {
        let mut warnings = Vec::new();
        let mut dirs = RootScan::new(roots).package_dirs(&mut warnings);
        dirs.sort();
        dirs
    }

    #[test]
    fn finds_a_project() {
        let tmp = TempDir::new().unwrap();
        let project = cargo_project(tmp.path(), "proj");

        assert_eq!(package_dirs(vec![tmp.path().to_path_buf()]), vec![project]);
    }

    #[test]
    fn finds_an_unbuilt_project() {
        // The whole point of scanning for manifests: a project with no target
        // directory is still a project worth managing. A `target/`-based scan
        // could not see this one.
        let tmp = TempDir::new().unwrap();
        let project = cargo_project(tmp.path(), "proj");
        assert!(!project.join("target").exists());

        assert_eq!(package_dirs(vec![tmp.path().to_path_buf()]), vec![project]);
    }

    #[test]
    fn finds_sibling_projects() {
        // Independent projects side by side, with no manifest above them, are
        // all found — the walk keeps going across siblings.
        let tmp = TempDir::new().unwrap();
        let a = cargo_project(tmp.path(), "a");
        let b = cargo_project(tmp.path(), "b");

        assert_eq!(package_dirs(vec![tmp.path().to_path_buf()]), vec![a, b]);
    }

    #[test]
    fn stops_at_the_first_manifest() {
        // A workspace root sits above its members; once the root's manifest is
        // found there is no need to look deeper, so a nested manifest below it
        // is not reported separately. Cargo resolves the members from the root.
        let tmp = TempDir::new().unwrap();
        let outer = cargo_project(tmp.path(), "outer");
        cargo_project(&outer, "nested/inner");

        assert_eq!(package_dirs(vec![tmp.path().to_path_buf()]), vec![outer]);
    }

    #[test]
    fn finds_a_dotted_project() {
        // Projects do turn up inside dotted directories, so a leading dot must
        // not prune the walk.
        let tmp = TempDir::new().unwrap();
        let dotted = cargo_project(tmp.path(), ".cargo-task/hidden");

        assert_eq!(package_dirs(vec![tmp.path().to_path_buf()]), vec![dotted]);
    }

    #[test]
    fn does_not_descend_into_a_target_dir() {
        // Build output can contain a whole project's worth of files (say, a
        // checked-out build-script fixture). A manifest buried in there is not
        // a project a person manages, so the target dir is never entered.
        //
        // The manifest sits beside the target rather than being the scan root,
        // so the walk must reach the target on its own and prune it there.
        let tmp = TempDir::new().unwrap();
        let bare = tmp.path().join("bare");
        fs::create_dir_all(&bare).unwrap();
        let target = built_target(&bare);
        cargo_project(&target, "debug/build/fixture");

        assert!(package_dirs(vec![tmp.path().to_path_buf()]).is_empty());
    }

    #[test]
    fn ignores_a_manifest_inside_a_git_store() {
        let tmp = TempDir::new().unwrap();
        cargo_project(tmp.path(), ".git/some/checkout");

        assert!(package_dirs(vec![tmp.path().to_path_buf()]).is_empty());
    }

    #[test]
    fn warns_about_a_root_that_is_not_there() {
        let tmp = TempDir::new().unwrap();
        let missing = tmp.path().join("absent");
        let mut warnings = Vec::new();

        let found = RootScan::new(vec![missing.clone()]).package_dirs(&mut warnings);

        assert!(found.is_empty());
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains(&missing.display().to_string()));
    }
}