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()));
}
}