cargo-ratchet 0.1.0

Prevent resolved versions in Cargo.lock from regressing below a committed, self-advancing baseline
Documentation
use std::ffi::OsString;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use cargo_metadata::MetadataCommand;

/// Locations of the lockfile and its ratchet baseline.
pub struct RatchetPaths {
    pub lockfile: PathBuf,
    pub ratchet_file: PathBuf,
}

impl RatchetPaths {
    /// Resolve the lockfile and baseline locations. Unless overridden, the
    /// lockfile is `Cargo.lock` at the workspace root (discovered via
    /// `cargo metadata`) and the baseline sits next to the lockfile as
    /// `.<lockfile name>.ratchet`.
    pub fn discover(
        manifest_path: Option<&Path>,
        lockfile: Option<PathBuf>,
        ratchet_file: Option<PathBuf>,
    ) -> Result<Self> {
        let lockfile = match lockfile {
            Some(lockfile) => lockfile,
            None => workspace_root(manifest_path)?.join("Cargo.lock"),
        };
        let ratchet_file = match ratchet_file {
            Some(ratchet_file) => ratchet_file,
            None => default_ratchet_path(&lockfile)?,
        };
        Ok(Self {
            lockfile,
            ratchet_file,
        })
    }
}

// `cargo metadata` is used only to locate the workspace root, never for
// dependency information: its `resolve` field requires running full
// resolution (dropping `--no-deps`), which can touch the network and
// regenerates the very `Cargo.lock` this tool exists to check. The committed
// lockfile bytes are the subject matter, so both sides of the comparison are
// parsed from lockfiles directly.
fn workspace_root(manifest_path: Option<&Path>) -> Result<PathBuf> {
    let mut command = MetadataCommand::new();
    if let Some(path) = manifest_path {
        command.manifest_path(path);
    }
    let metadata = command
        .no_deps()
        .exec()
        .context("failed to locate workspace root via `cargo metadata`")?;
    Ok(metadata.workspace_root.into_std_path_buf())
}

/// `.<lockfile name>.ratchet` next to the lockfile, e.g. `.Cargo.lock.ratchet`.
fn default_ratchet_path(lockfile: &Path) -> Result<PathBuf> {
    let lockfile_name = lockfile
        .file_name()
        .with_context(|| format!("lockfile path has no file name: {}", lockfile.display()))?;
    let mut ratchet_name = OsString::from(".");
    ratchet_name.push(lockfile_name);
    ratchet_name.push(".ratchet");
    Ok(lockfile.with_file_name(ratchet_name))
}