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]),
    })
}