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, } impl RootScan { pub fn new(roots: Vec) -> 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) -> Vec { 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) -> Result> { 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) -> Vec { 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())); } }