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.
//! What each verb does.

use std::io::Write;
use std::path::PathBuf;
use std::process::Command;

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

use crate::Result;
use crate::alias::{self, Outcome};
use crate::cli::{Alias, Cli, Completions, Config, Set, Tag, Verb};
use crate::config::{self, Scaffold};
use crate::git;
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::Update) => update_tool(),
        Some(Verb::Version) => tool_version(),
        Some(Verb::Alias(args)) => install_alias(args),
        Some(Verb::Completions(args)) => completions(args),
        Some(Verb::Config(args)) => write_config(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(())
}

/// Reinstall the tool the way `cargo install` installed it. Cargo decides
/// what is new: it skips the work when the newest published version is
/// already there, and it prints either outcome itself.
fn update_tool() -> Result<()> {
    let status = Command::new("cargo")
        .args(["install", "semver-sync", "--locked"])
        .status()
        .map_err(|err| format!("cannot run `cargo install semver-sync`: {err}"))?;

    if !status.success() {
        // cargo named its own failure on stderr, one build at a time.
        return Err(String::from(REPORTED));
    }

    now_installed();

    Ok(())
}

/// Ask the installed binary what it is now. This process cannot answer: it is
/// the copy cargo just replaced, so its build-time version is stale.
fn now_installed() {
    let report = installed_exe()
        .and_then(|exe| Command::new(exe).arg("version").output().ok())
        .filter(|done| done.status.success())
        .map(|done| String::from_utf8_lossy(&done.stdout).trim_end().to_string())
        .unwrap_or_else(|| String::from("run `ss version` to see the version now installed"));

    out(&report);
}

/// Where `cargo install` puts binaries: `$CARGO_HOME/bin`, falling back to
/// `~/.cargo/bin` the way cargo itself does.
fn installed_exe() -> Option<PathBuf> {
    let home = match std::env::var_os("CARGO_HOME") {
        Some(home) => PathBuf::from(home),
        None => PathBuf::from(std::env::var_os("HOME")?).join(".cargo"),
    };

    let exe = home.join("bin").join("semver-sync");

    exe.is_file().then_some(exe)
}

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())?;

    // Read before anything is written: a file that does not parse has to stop
    // the run while every manifest is still where it left it.
    let config = config::Config::from_repo(workspace.root())?.unwrap_or_default();

    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 config.git_tag {
            out(&git_line(&format!("tag v{next} and push"), true));
        }
    }

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

    if !dry_run {
        workspace.refresh_lock()?;

        // The tag is part of the write, so a run that could not make it or
        // could not send it is not a run that finished.
        if config.git_tag {
            git::release(workspace.root(), next)?;
            out(&git_line(&format!("tagged and pushed v{next}"), false));
        }

        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}")
}

/// The git step in the column the manifest lines use, so one run reads as one
/// list of what happened to the version.
fn git_line(what: &str, dry_run: bool) -> String {
    let marker = if dry_run { " (dry-run)" } else { "" };

    format!("  {:<12} {what}{marker}", "git")
}

/// 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(())
}

/// Write the file, then say which file a run reads. The repo's own sits in
/// front of the user's, so the one just written can be the one behind.
fn write_config(args: Config) -> Result<()> {
    let path = match config::scaffold(args.global)? {
        Scaffold::Written { path } => {
            out(&format!("wrote {}", path.display()));
            path
        }
        Scaffold::Existing { path } => {
            out(&format!("{} is already there", path.display()));
            path
        }
    };

    if let Some(read) = config::Config::discover()? {
        let message = if read.path == path {
            String::from("  it is the file that is read")
        } else {
            format!(
                "  {} is read first, so it wins over this one",
                read.path.display()
            )
        };

        out(&message);
    }

    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);
    }
}