cargo-reclaim 0.2.1

Safe Cargo cleanup for target directories, stale artifacts, and Cargo home caches
Documentation
use std::collections::HashSet;
use std::error::Error;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::process::Command;

use crate::planner::PlannerOptions;
use rustc_stable_hash::StableSipHasher128;

pub type ToolchainHashResult<T> = Result<T, ToolchainHashError>;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolchainHashError {
    RustupToolchainListFailed {
        status: Option<i32>,
        stderr: String,
    },
    RustupRunRustcFailed {
        toolchain: String,
        status: Option<i32>,
        stderr: String,
    },
    EmptyRustcVersion {
        toolchain: String,
    },
    InvalidInstalledToolchainLine {
        line: String,
    },
    Io {
        command: String,
        message: String,
    },
    Utf8 {
        command: String,
        message: String,
    },
}

impl fmt::Display for ToolchainHashError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::RustupToolchainListFailed { status, stderr } => write!(
                formatter,
                "rustup toolchain list failed with status {}: {}",
                status_label(*status),
                stderr.trim()
            ),
            Self::RustupRunRustcFailed {
                toolchain,
                status,
                stderr,
            } => write!(
                formatter,
                "rustup run {toolchain} rustc -vV failed with status {}: {}",
                status_label(*status),
                stderr.trim()
            ),
            Self::EmptyRustcVersion { toolchain } => write!(
                formatter,
                "rustup run {toolchain} rustc -vV succeeded but did not emit version details"
            ),
            Self::InvalidInstalledToolchainLine { line } => write!(
                formatter,
                "rustup toolchain list reported an invalid installed toolchain line `{line}`"
            ),
            Self::Io { command, message } => {
                write!(formatter, "failed to run {command}: {message}")
            }
            Self::Utf8 { command, message } => {
                write!(formatter, "{command} emitted invalid UTF-8: {message}")
            }
        }
    }
}

impl Error for ToolchainHashError {}

pub trait ToolchainHashResolver {
    fn installed_toolchains(&self) -> ToolchainHashResult<Vec<String>>;
    fn toolchain_rustc_hash(&self, toolchain: &str) -> ToolchainHashResult<u64>;
}

#[derive(Debug, Clone, Copy, Default)]
struct CommandToolchainHashResolver;

impl ToolchainHashResolver for CommandToolchainHashResolver {
    fn installed_toolchains(&self) -> ToolchainHashResult<Vec<String>> {
        let output = Command::new("rustup")
            .arg("toolchain")
            .arg("list")
            .output()
            .map_err(|source| ToolchainHashError::Io {
                command: "rustup toolchain list".to_string(),
                message: source.to_string(),
            })?;
        if !output.status.success() {
            return Err(ToolchainHashError::RustupToolchainListFailed {
                status: output.status.code(),
                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
            });
        }
        let stdout =
            String::from_utf8(output.stdout).map_err(|source| ToolchainHashError::Utf8 {
                command: "rustup toolchain list".to_string(),
                message: source.to_string(),
            })?;
        parse_installed_toolchains(&stdout)
    }

    fn toolchain_rustc_hash(&self, toolchain: &str) -> ToolchainHashResult<u64> {
        let output = Command::new("rustup")
            .arg("run")
            .arg(toolchain)
            .arg("rustc")
            .arg("-vV")
            .output()
            .map_err(|source| ToolchainHashError::Io {
                command: format!("rustup run {toolchain} rustc -vV"),
                message: source.to_string(),
            })?;
        if !output.status.success() {
            return Err(ToolchainHashError::RustupRunRustcFailed {
                toolchain: toolchain.to_string(),
                status: output.status.code(),
                stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
            });
        }
        let stdout =
            String::from_utf8(output.stdout).map_err(|source| ToolchainHashError::Utf8 {
                command: format!("rustup run {toolchain} rustc -vV"),
                message: source.to_string(),
            })?;
        rustc_version_hash(toolchain, &stdout)
    }
}

pub fn resolve_toolchain_hash_options(
    options: &mut PlannerOptions,
    resolver: &impl ToolchainHashResolver,
) -> ToolchainHashResult<()> {
    if !options.keep_installed_toolchains && options.keep_toolchains.is_empty() {
        return Ok(());
    }

    let installed_toolchains = if options.keep_installed_toolchains {
        Some(resolver.installed_toolchains()?)
    } else {
        None
    };
    let mut seen_toolchains: HashSet<&str> = HashSet::new();
    let mut seen_hashes = options
        .keep_rustc_hashes
        .iter()
        .copied()
        .collect::<HashSet<_>>();
    for toolchain in options.keep_toolchains.iter().map(String::as_str).chain(
        installed_toolchains
            .iter()
            .flat_map(|toolchains| toolchains.iter().map(String::as_str)),
    ) {
        if !seen_toolchains.insert(toolchain) {
            continue;
        }
        let rustc_hash = resolver.toolchain_rustc_hash(toolchain)?;
        if seen_hashes.insert(rustc_hash) {
            options.keep_rustc_hashes.push(rustc_hash);
        }
    }

    Ok(())
}

pub fn resolve_command_toolchain_hash_options(
    options: &mut PlannerOptions,
) -> ToolchainHashResult<()> {
    resolve_toolchain_hash_options(options, &CommandToolchainHashResolver)
}

fn parse_installed_toolchains(stdout: &str) -> ToolchainHashResult<Vec<String>> {
    let mut toolchains = Vec::new();
    for line in stdout
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
    {
        let mut fields = line.split_whitespace();
        let Some(name) = fields.next() else {
            continue;
        };
        if name.starts_with('(') {
            return Err(ToolchainHashError::InvalidInstalledToolchainLine {
                line: line.to_string(),
            });
        }
        toolchains.push(name.to_string());
    }
    Ok(toolchains)
}

fn rustc_version_hash(toolchain: &str, stdout: &str) -> ToolchainHashResult<u64> {
    if stdout.trim().is_empty() {
        return Err(ToolchainHashError::EmptyRustcVersion {
            toolchain: toolchain.to_string(),
        });
    }
    let mut hasher = StableSipHasher128::new();
    stdout.hash(&mut hasher);
    Ok(Hasher::finish(&hasher))
}

fn status_label(status: Option<i32>) -> String {
    status
        .map(|status| status.to_string())
        .unwrap_or_else(|| "unknown".to_string())
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use super::*;

    #[derive(Default)]
    struct FakeResolver {
        installed: Vec<String>,
        hashes: HashMap<String, u64>,
    }

    impl ToolchainHashResolver for FakeResolver {
        fn installed_toolchains(&self) -> ToolchainHashResult<Vec<String>> {
            Ok(self.installed.clone())
        }

        fn toolchain_rustc_hash(&self, toolchain: &str) -> ToolchainHashResult<u64> {
            self.hashes.get(toolchain).copied().ok_or_else(|| {
                ToolchainHashError::EmptyRustcVersion {
                    toolchain: toolchain.to_string(),
                }
            })
        }
    }

    #[test]
    fn parses_installed_toolchain_names_without_status_annotations() -> Result<(), Box<dyn Error>> {
        let toolchains = parse_installed_toolchains(
            "stable-x86_64-unknown-linux-gnu (default)\nnightly-x86_64-unknown-linux-gnu\n",
        )?;

        assert_eq!(
            toolchains,
            [
                "stable-x86_64-unknown-linux-gnu",
                "nightly-x86_64-unknown-linux-gnu"
            ]
        );
        Ok(())
    }

    #[test]
    fn hashes_rustc_verbose_version_output() -> Result<(), Box<dyn Error>> {
        assert_eq!(
            rustc_version_hash("stable", "rustc 1.90.0\nhost: x86_64-unknown-linux-gnu\n")?,
            rustc_version_hash("stable", "rustc 1.90.0\nhost: x86_64-unknown-linux-gnu\n")?
        );
        assert_ne!(
            rustc_version_hash("stable", "rustc 1.90.0\nhost: x86_64-unknown-linux-gnu\n")?,
            rustc_version_hash("nightly", "rustc 1.91.0\nhost: x86_64-unknown-linux-gnu\n")?
        );
        Ok(())
    }

    #[test]
    fn rejects_empty_rustc_verbose_version_output() {
        assert!(matches!(
            rustc_version_hash("stable", ""),
            Err(ToolchainHashError::EmptyRustcVersion { toolchain }) if toolchain == "stable"
        ));
    }

    #[test]
    fn resolves_named_and_installed_toolchains_into_existing_hashes() -> Result<(), Box<dyn Error>>
    {
        let mut resolver = FakeResolver {
            installed: vec!["stable".to_string(), "beta".to_string()],
            hashes: HashMap::new(),
        };
        resolver.hashes.insert("stable".to_string(), 7);
        resolver.hashes.insert("beta".to_string(), 8);
        resolver.hashes.insert("nightly".to_string(), 7);
        let mut options = PlannerOptions {
            keep_rustc_hashes: vec![3],
            keep_installed_toolchains: true,
            keep_toolchains: vec!["nightly".to_string()],
            ..PlannerOptions::default()
        };

        resolve_toolchain_hash_options(&mut options, &resolver)?;

        assert_eq!(options.keep_rustc_hashes, [3, 7, 8]);
        Ok(())
    }
}