metadata.rs
raw
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<Metadata> {
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<Item = PathBuf>,
warnings: &mut Vec<String>,
) -> Vec<Project> {
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<Project> {
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]),
})
}