semver-sync 0.1.0

Keep one version across every crate in a cargo workspace; bumps are checked as docker tags before any manifest is written.
//! What each verb does.

use std::io::Write;

use clap::CommandFactory;
use semver::Version;

use crate::Result;
use crate::alias::{self, Outcome};
use crate::cli::{Alias, Cli, Completions, Set, Tag, Verb};
use crate::version;
use crate::workspace::Workspace;

/// A failure whose details already went to stderr, one manifest at a time.
const REPORTED: &str = "";

pub fn run(verb: Option<Verb>) -> Result<()> {
    match verb {
        None => list(),
        Some(Verb::Patch(args)) => bump_workspace(version::Level::Patch, args.dry_run),
        Some(Verb::Minor(args)) => bump_workspace(version::Level::Minor, args.dry_run),
        Some(Verb::Major(args)) => bump_workspace(version::Level::Major, args.dry_run),
        Some(Verb::Tag(args)) => tag_workspace(args),
        Some(Verb::Set(args)) => set_workspace(args),
        Some(Verb::Version) => tool_version(),
        Some(Verb::Alias(args)) => install_alias(args),
        Some(Verb::Completions(args)) => completions(args),
    }
}

/// Bare `semver-sync`: one line per crate, the version every image tag and
/// healthcheck endpoint reads.
fn list() -> Result<()> {
    let workspace = Workspace::discover()?;

    for member in workspace.members() {
        out(&format!("  {:<12} {}", member.name, member.version));
    }

    Ok(())
}

fn tool_version() -> Result<()> {
    out(&format!("semver-sync {}", env!("CARGO_PKG_VERSION")));

    Ok(())
}

fn bump_workspace(level: version::Level, dry_run: bool) -> Result<()> {
    let workspace = Workspace::discover()?;
    let current = workspace.shared_version()?;
    let next = version::bump(&current, level)?;

    write_workspace(workspace, &next, dry_run)
}

fn tag_workspace(args: Tag) -> Result<()> {
    let workspace = Workspace::discover()?;
    let current = workspace.shared_version()?;
    let next = version::tag(&current, &args.tag)?;

    write_workspace(workspace, &next, args.dry_run)
}

fn set_workspace(args: Set) -> Result<()> {
    let workspace = Workspace::discover()?;
    let next = version::parse(&args.version)?;

    write_workspace(workspace, &next, args.dry_run)
}

/// Move every manifest that holds a version — package versions, inherited
/// `[workspace.package]` versions and the path pins between crates — then let
/// cargo resolve the workspace again, which refreshes Cargo.lock.
fn write_workspace(mut workspace: Workspace, next: &Version, dry_run: bool) -> Result<()> {
    version::check_image_tag(&next.to_string())?;

    let before = workspace.snapshot()?;
    let crates = workspace.member_names();
    let mut failed = false;

    for index in 0..workspace.target_count() {
        let target = workspace.target_mut(index);

        match target.version() {
            Ok(from) => {
                target.rewrite(next, &crates);

                if dry_run {
                    out(&line(&target.name(), &from, next, true));
                } else if let Err(err) = target.save() {
                    note(&format!("semver-sync: {err}"));
                    failed = true;
                } else {
                    out(&line(&target.name(), &from, next, false));
                }
            }
            Err(err) => {
                note(&format!("semver-sync: {err}"));
                failed = true;
            }
        }
    }

    // A dry run that moved a manifest is a bug, not a report.
    if dry_run {
        workspace.unchanged_since(&before)?;
    }

    if failed {
        return Err(String::from(REPORTED));
    }

    if !dry_run {
        workspace.refresh_lock()?;
        next_steps(&workspace, next);
    }

    Ok(())
}

fn line(name: &str, from: &Version, to: &Version, dry_run: bool) -> String {
    let marker = if dry_run { " (dry-run)" } else { "" };

    format!("  {name:<12} {from} -> {to}{marker}")
}

/// These repos ship through `bin/build.sh`, so point at it when it is there.
fn next_steps(workspace: &Workspace, next: &Version) {
    if !workspace.root().join("bin/build.sh").is_file() {
        return;
    }

    out("");
    out("next steps:");

    if workspace.root().join("bin/unit_test.sh").is_file() {
        out("  bin/unit_test.sh");
    }

    out(&format!("  bin/build.sh     # pushes images tagged {next}"));
}

fn install_alias(args: Alias) -> Result<()> {
    match alias::install(args)? {
        Outcome::Written { path, line } => out(&format!(
            "wrote `{line}` to {} — reload with: source {}",
            path.display(),
            path.display()
        )),
        Outcome::Already { path } => out(&format!("the alias is already in {}", path.display())),
    }

    Ok(())
}

fn completions(args: Completions) -> Result<()> {
    let mut command = Cli::command();

    clap_complete::generate(
        args.shell,
        &mut command,
        "semver-sync",
        &mut std::io::stdout(),
    );

    Ok(())
}

/// One line to stdout. A closed pipe (`semver-sync | head`) is a normal way to
/// stop reading, not a panic.
fn out(text: &str) {
    let stdout = std::io::stdout();
    let mut handle = stdout.lock();

    if writeln!(handle, "{text}").is_err() {
        std::process::exit(0);
    }
}

/// One line to stderr. If that channel is gone as well, the exit status is all
/// that is left to give.
fn note(text: &str) {
    let stderr = std::io::stderr();
    let mut handle = stderr.lock();

    if writeln!(handle, "{text}").is_err() {
        std::process::exit(1);
    }
}