cargo-ratchet 0.1.0

Prevent resolved versions in Cargo.lock from regressing below a committed, self-advancing baseline
Documentation
use std::collections::BTreeMap;

use cargo_lock::{Dependency, Lockfile, Name, Package, SourceId};

use crate::Violation;

/// Compare `current` against `baseline` and report every regression of
/// resolved versions.
///
/// Two layers are compared, per baseline entry (an entry absent from
/// `current` passes — removal is allowed):
///
/// - Package versions, keyed by package name across all sources. Source
///   changes (e.g. crates.io -> git) do not exempt the comparison; versions
///   are compared anyway by name.
/// - Resolved crates.io dependency edges from the highest-version package of
///   each name.
pub fn compute_violations(baseline: &Lockfile, current: &Lockfile) -> Vec<Violation> {
    let baseline_packages = highest_version_packages(&baseline.packages);
    let current_packages = highest_version_packages(&current.packages);

    let mut violations = Vec::new();
    for (name, baseline_package) in &baseline_packages {
        let Some(current_package) = current_packages.get(name).copied() else {
            continue;
        };

        if current_package.version < baseline_package.version {
            violations.push(Violation::PackageVersionRegressed {
                name: name.to_string(),
                baseline_version: baseline_package.version.clone(),
                current_version: current_package.version.clone(),
                baseline_source: baseline_package.source.as_ref().map(ToString::to_string),
                current_source: current_package.source.as_ref().map(ToString::to_string),
            });
        }

        let baseline_deps = highest_version_registry_deps(baseline_package);
        let current_deps = highest_version_registry_deps(current_package);
        for (dep_name, baseline_dep) in &baseline_deps {
            let Some(current_dep) = current_deps.get(dep_name).copied() else {
                continue;
            };
            if current_dep.version < baseline_dep.version {
                violations.push(Violation::DependencyEdgeRegressed {
                    package_name: name.to_string(),
                    baseline_package_version: baseline_package.version.clone(),
                    current_package_version: current_package.version.clone(),
                    dependency_name: dep_name.to_string(),
                    baseline_dependency_version: baseline_dep.version.clone(),
                    current_dependency_version: current_dep.version.clone(),
                });
            }
        }
    }

    violations
}

/// Highest-version package per name, across all sources. Multiple versions of
/// the same package name appear legitimately during ecosystem transitions
/// (e.g. `http` 0.2 and `http` 1); only the highest per name participates in
/// the ratchet, on each side.
fn highest_version_packages(packages: &[Package]) -> BTreeMap<&Name, &Package> {
    let mut by_name: BTreeMap<&Name, &Package> = BTreeMap::new();
    for package in packages {
        by_name
            .entry(&package.name)
            .and_modify(|highest| {
                if package.version > highest.version {
                    *highest = package;
                }
            })
            .or_insert(package);
    }
    by_name
}

/// Highest-version default-registry (crates.io) dependency edge per name.
/// Default-registry package versions are immutable semver release identities,
/// which is what makes a monotonic version check meaningful at the edge
/// layer. Git and path deps are mutable behind their reference and would need
/// a source/commit identity policy rather than version ordering; alternate
/// registries live behind different identity and access controls. All three
/// are skipped.
fn highest_version_registry_deps(package: &Package) -> BTreeMap<&Name, &Dependency> {
    let mut by_name: BTreeMap<&Name, &Dependency> = BTreeMap::new();
    for dep in package
        .dependencies
        .iter()
        .filter(|dep| is_default_registry_dep(dep))
    {
        by_name
            .entry(&dep.name)
            .and_modify(|highest| {
                if dep.version > highest.version {
                    *highest = dep;
                }
            })
            .or_insert(dep);
    }
    by_name
}

fn is_default_registry_dep(dep: &Dependency) -> bool {
    dep.source
        .as_ref()
        .is_some_and(SourceId::is_default_registry)
}