semver-sync 0.1.2

Keep one version across every crate in a cargo workspace; bumps are checked as docker tags before any manifest is written.
//! The workspace cargo resolves, and the manifests that hold its version.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;

use semver::Version;
use serde::Deserialize;

use crate::Result;
use crate::manifest::Manifest;

/// A crate cargo lists as a workspace member.
#[derive(Debug)]
pub struct Member {
    pub name: String,
    pub version: Version,
}

/// The workspace, with the manifests that declare a version — the only ones a
/// bump can write.
#[derive(Debug)]
pub struct Workspace {
    root: PathBuf,
    members: Vec<Member>,
    targets: Vec<Manifest>,
}

impl Workspace {
    /// Ask cargo, so members, path dependencies and inheritance are settled the
    /// same way every build settles them.
    pub fn discover() -> Result<Self> {
        let metadata = cargo_metadata()?;
        let root = metadata.workspace_root;
        let root_path = root_manifest(&root);
        let mut members = Vec::new();
        let mut targets: Vec<Manifest> = Vec::new();
        let mut written: BTreeSet<PathBuf> = BTreeSet::new();

        for package in metadata.packages {
            let version = Version::parse(&package.version)
                .map_err(|err| format!("{}: {err}", package.name))?;
            members.push(Member {
                name: package.name,
                version,
            });

            let manifest = Manifest::load(&package.manifest_path)?;
            if manifest.has_version() && written.insert(package.manifest_path) {
                targets.push(manifest);
            }
        }

        // A virtual workspace root is not a member, and `[workspace.package]
        // version` is what its crates inherit, so it needs loading on its own.
        if !written.contains(&root_path) {
            let root_manifest = Manifest::load(&root_path)?;

            if root_manifest.has_version() {
                targets.push(root_manifest);
            }
        }

        if targets.is_empty() {
            return Err(format!("{} declares no version to move", root.display()));
        }

        Ok(Self {
            root,
            members,
            targets,
        })
    }

    pub fn root(&self) -> &Path {
        &self.root
    }

    pub fn members(&self) -> &[Member] {
        &self.members
    }

    /// Every member name, so a `path` dependency is only re-pinned when it
    /// points at a crate this run really moves.
    pub fn member_names(&self) -> BTreeSet<String> {
        self.members
            .iter()
            .map(|member| member.name.clone())
            .collect()
    }

    pub fn target_count(&self) -> usize {
        self.targets.len()
    }

    pub fn target_mut(&mut self, index: usize) -> &mut Manifest {
        &mut self.targets[index]
    }

    /// The one version the workspace shares. Members that disagree cannot be
    /// moved by a level or a tag, because there is no single number to move.
    pub fn shared_version(&self) -> Result<Version> {
        let mut sorted = self
            .members
            .iter()
            .map(|member| member.version.clone())
            .collect::<Vec<_>>();
        sorted.sort();
        sorted.dedup();

        match sorted.len() {
            1 => Ok(sorted.pop().expect("one version")),
            0 => Err(String::from("this workspace has no crates")),
            _ => Err(format!(
                "the crates do not share a version ({}); use `semver-sync set <VERSION>`",
                sorted
                    .iter()
                    .map(Version::to_string)
                    .collect::<Vec<_>>()
                    .join(", ")
            )),
        }
    }

    /// Bytes on disk before a run, so a dry run can prove it touched nothing.
    pub fn snapshot(&self) -> Result<Vec<(PathBuf, Vec<u8>)>> {
        self.targets
            .iter()
            .map(|target| {
                std::fs::read(target.path())
                    .map(|bytes| (target.path().to_path_buf(), bytes))
                    .map_err(|err| format!("cannot read {}: {err}", target.path().display()))
            })
            .collect()
    }

    pub fn unchanged_since(&self, before: &[(PathBuf, Vec<u8>)]) -> Result<()> {
        for (path, bytes) in before {
            let after =
                std::fs::read(path).map_err(|err| format!("cannot read {path:?}: {err}"))?;

            if &after != bytes {
                return Err(format!(
                    "a dry run rewrote {path:?}; the manifests were not left alone"
                ));
            }
        }

        Ok(())
    }

    /// Let cargo resolve the workspace again, which also refreshes Cargo.lock.
    /// A bump is only real once the path pins and the lock agree.
    pub fn refresh_lock(&self) -> Result<()> {
        let output = Command::new("cargo")
            .args([
                "metadata",
                "--format-version",
                "1",
                "--manifest-path",
                &root_manifest(&self.root).to_string_lossy(),
            ])
            .output()
            .map_err(|err| format!("cannot run cargo metadata: {err}"))?;

        if !output.status.success() {
            return Err(format!(
                "the workspace does not resolve after the bump\n{}\n  compare the manifests: git diff -- '*Cargo.toml'",
                cargo_error(&output.stderr)
            ));
        }

        Ok(())
    }
}

/// The repo root cargo resolves, with no manifest loaded: a config file sits
/// beside `Cargo.toml` whether or not this workspace holds a version to move.
pub fn repo_root() -> Result<PathBuf> {
    Ok(cargo_metadata()?.workspace_root)
}

/// What cargo says the workspace is. `--no-deps` lists exactly the workspace
/// members, inheritance settled.
fn cargo_metadata() -> Result<Metadata> {
    let output = Command::new("cargo")
        .args(["metadata", "--no-deps", "--format-version", "1"])
        .output()
        .map_err(|err| format!("cannot run cargo metadata: {err}"))?;

    if !output.status.success() {
        return Err(cargo_error(&output.stderr));
    }

    serde_json::from_slice(&output.stdout)
        .map_err(|err| format!("cannot read cargo metadata: {err}"))
}

fn root_manifest(root: &Path) -> PathBuf {
    root.join("Cargo.toml")
}

/// Keep every line cargo wrote: its first line names the failure, and the
/// lines under it say which member and why.
fn cargo_error(stderr: &[u8]) -> String {
    let text = String::from_utf8_lossy(stderr);
    let trimmed = text.trim();

    if trimmed.is_empty() {
        return String::from("cargo failed without a message");
    }

    trimmed.to_string()
}

#[derive(Deserialize)]
struct Metadata {
    workspace_root: PathBuf,
    packages: Vec<Package>,
}

#[derive(Deserialize)]
struct Package {
    name: String,
    version: String,
    manifest_path: PathBuf,
}