use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, bail}; use serde::Deserialize; use super::{Project, SourceKind}; use crate::fsutil::is_cargo_target_dir; /// The slice of `cargo metadata` output we care about. #[derive(Debug, Deserialize)] struct Metadata { workspace_root: PathBuf, target_directory: PathBuf, } /// Ask cargo where a package's workspace root and target directory are. /// /// Cargo is the only thing that knows this for certain. A package directory is /// not necessarily its own workspace root, so its build output may land in an /// ancestor's `target/`; and `CARGO_TARGET_DIR` or `build.target-dir` can move /// the target directory anywhere at all. Rather than reimplement that /// resolution and watch it drift out of step with cargo, we ask cargo. /// /// `--no-deps` keeps this to a manifest read with no dependency resolution, and /// `--offline` guarantees that looking at a project can never hit the network. fn resolve(package_dir: &Path) -> Result { let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); let output = Command::new(cargo) .arg("metadata") .arg("--no-deps") .arg("--format-version") .arg("1") .arg("--offline") .arg("--manifest-path") .arg(package_dir.join("Cargo.toml")) .output() .context("running `cargo metadata`")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!("`cargo metadata` failed: {}", stderr.trim()); } serde_json::from_slice(&output.stdout).context("parsing `cargo metadata` output") } /// Turn package directories into projects, dropping any cargo cannot make sense of. /// /// Shared by the sources that yield package directories rather than target /// directories, so they resolve workspaces identically. pub fn resolve_packages( kind: SourceKind, package_dirs: impl IntoIterator, warnings: &mut Vec, ) -> Vec { let mut projects = Vec::new(); for dir in package_dirs { if !dir.join("Cargo.toml").is_file() { warnings.push(format!( "{kind}: {} is not a cargo project (no Cargo.toml); skipping", dir.display() )); continue; } match resolve(&dir) { Ok(metadata) => { projects.push(Project { root: metadata.workspace_root, target: metadata.target_directory.clone(), found_by: BTreeSet::from([kind]), }); projects.extend(orphaned_target(&dir, &metadata.target_directory, kind)); } Err(err) => warnings.push(format!("{kind}: {}: {err:#}", dir.display())), } } projects } /// A target directory a package has outgrown, if it has one. /// /// A package that was built on its own and has since been absorbed into a /// workspace keeps the target directory it had at the time. Cargo builds /// somewhere else now and will never mention or touch the old one again — so /// asking cargo, which is otherwise the only honest way to locate build output, /// walks straight past it. It is still cargo's own output, still sitting beside /// a manifest, still regenerable, and still taking up room. /// /// It is worth being precise about why this is safe to delete on a weaker /// signal than usual: the tag says cargo made it, the manifest says it belongs /// to this package, and the age check applies to it exactly as to any other /// target. The only thing missing is cargo's blessing, and cargo has forgotten /// it exists. fn orphaned_target(package_dir: &Path, current_target: &Path, kind: SourceKind) -> Option { let orphan = package_dir.join("target"); if orphan == current_target || !is_cargo_target_dir(&orphan) { return None; } Some(Project { // Its own directory, not the workspace root: this output belongs to the // package that was built back when it stood alone. root: package_dir.to_path_buf(), target: orphan, found_by: BTreeSet::from([kind]), }) }