rinkaku 0.6.22

Condense PR diffs into signatures and their dependencies for LLM-friendly review
//! `rinkaku self-update`: replaces the running binary with the latest
//! GitHub release, following the same `self_update` crate setup as
//! `hiro-o918/skem`'s `src/self_update.rs` (same crate version, feature
//! set, and `Update::configure()` shape) for consistency across this
//! author's CLIs.
//!
//! Kept in the `rinkaku` bin crate rather than `rinkaku-core`: this is
//! process/network IO tied to how *this specific binary* is distributed
//! (GitHub Releases asset naming), not part of the pure diff-condensation
//! core.
//!
//! See README's Release section for how `rinkaku` and `rinkaku-core` are
//! versioned and tagged independently.

use crate::spinner::Spinner;
use anyhow::{Context, Result};
use std::io::IsTerminal;

const REPO_OWNER: &str = "hiro-o918";
const REPO_NAME: &str = "rinkaku";
const BIN_NAME: &str = "rinkaku";

/// Whether [`run_self_update`] actually replaced the binary on disk. A
/// plain `Ok(())` cannot say this: the "already up to date", "cancelled",
/// and "the crate reported no update" paths all succeed without updating.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateOutcome {
    Updated,
    NotUpdated,
}

/// Whether [`run_self_update`] should print its own "New release found"
/// line, or stay quiet because the caller already announced the version.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Announcement {
    Print,
    AlreadyAnnounced,
}

/// How the update prompt should behave, decided up front from `--yes` and
/// whether stdin is a terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfirmMode {
    /// Skip the confirmation prompt (`--yes` was passed).
    Skip,
    /// Show the interactive confirmation prompt (stdin is a TTY).
    Prompt,
    /// Refuse to update: `--yes` was not passed and stdin is not a TTY, so
    /// there is no one to answer the prompt.
    RefuseNonInteractive,
}

/// Decides how `self-update` should confirm before installing, without
/// touching the terminal or the network — kept pure so the three cases are
/// unit-testable directly.
///
/// This matters beyond UX, for two independent reasons:
///
/// - Confirmation is now handled entirely by our own code (`is_affirmative`
///   below), not the `self_update` crate's `confirm()` — the crate is
///   always run with `no_confirm(true)` so it never prompts on its own
///   (see `run_self_update`'s doc comment for why). `RefuseNonInteractive`
///   is what actually blocks running non-interactively.
/// - Even with our own prompt, reading from a closed/non-TTY stdin
///   immediately yields an empty line (EOF), and `is_affirmative` treats
///   empty as *not* affirmative (deliberately the opposite of the crate's
///   own `confirm()`, which treats empty as "yes" — see its doc comment).
///   `RefuseNonInteractive` still exists as a defense-in-depth guard
///   before that: it fails fast with a clear message instead of silently
///   reading an empty line and declining, and it means an accidental
///   `rinkaku self-update` (e.g. a typo of `--base main`, see PR review)
///   can no longer be interpreted as any kind of answer when stdin isn't a
///   terminal.
fn confirm_mode(yes: bool, stdin_is_tty: bool) -> ConfirmMode {
    if yes {
        ConfirmMode::Skip
    } else if stdin_is_tty {
        ConfirmMode::Prompt
    } else {
        ConfirmMode::RefuseNonInteractive
    }
}

/// Whether a confirmation answer counts as "yes". The single rule for
/// consent across every update prompt in this binary, including
/// `update_prompt`'s pre-analysis one.
///
/// This is the deliberate opposite of the `self_update` crate's own
/// `confirm()` helper, which treats an *empty* response as "yes" (see
/// `confirm_mode`'s doc comment for why that default is unsafe for us):
/// requiring an explicit `y`/`yes` means a stray newline or a
/// misunderstood prompt can never be read as consent to replace the
/// running binary.
pub(crate) fn is_affirmative(answer: &str) -> bool {
    matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
}

/// Builds the same `self_update::backends::github::Update` configuration
/// [`run_self_update`] and [`check_update_available`] both need, so the two
/// entry points (the `self-update` subcommand and the TUI's background
/// version check) cannot drift apart on target/identifier/archive-path
/// wiring. See [`run_self_update`]'s own doc comment for why
/// `no_confirm(true)`/`show_output(false)` are always on.
fn build_updater(
    current_version: &str,
    target: &str,
) -> Result<Box<dyn self_update::update::ReleaseUpdate>> {
    self_update::backends::github::Update::configure()
        .repo_owner(REPO_OWNER)
        .repo_name(REPO_NAME)
        .bin_name(BIN_NAME)
        .target(target)
        .current_version(current_version)
        .identifier(&release_asset_name(BIN_NAME, target))
        .bin_path_in_archive(&archive_bin_path(BIN_NAME, target))
        .no_confirm(true)
        .show_output(false)
        .show_download_progress(true)
        .build()
        .map_err(anyhow::Error::from)
}

/// Runs `self-update`: downloads and installs the latest GitHub release
/// asset matching the running binary's target triple, replacing the
/// current executable in place.
///
/// All user-facing messaging is owned by this function rather than the
/// `self_update` crate's own `println!`s, which is why the builder below
/// always sets `.no_confirm(true)` and `.show_output(false)`: reading
/// `self_update` v0.42's `update_extended()` (`src/update.rs`) shows every
/// line it would otherwise print — including `Checking target-arch...`,
/// `New release found! v{cur} --> v{new}`, and, notably, `New release is
/// {*NOT* }compatible` — is gated behind `show_output` (via its internal
/// `println`/`print_flush` helpers), and the `"{bin} release status:"`
/// block plus its own confirmation prompt are gated behind
/// `show_output() || !no_confirm()`, i.e. `show_output(false)` +
/// `no_confirm(true)` together skip both. The `*NOT* compatible` line in
/// particular is actively misleading for us: it comes from
/// `self_update::version::bump_is_compatible`, which treats *any* 0.x
/// minor bump (e.g. 0.3.x -> 0.4.0) as incompatible by semver convention —
/// correct for that crate's general-purpose semantics, wrong for a
/// pre-1.0 tool whose 0.x minor bumps are just ordinary releases.
/// `.show_download_progress(true)` is kept on: the download progress bar
/// is rendered by `indicatif` directly (`Download::download_to`), not by
/// any of the suppressed `println!`s, so it stays as a real progress
/// indicator with none of the chatter.
///
/// `yes` corresponds to the `--yes`/`-y` flag; TTY detection (the other
/// input to `confirm_mode`) is read here at the composition-root boundary
/// rather than passed in, since it is itself a form of environment IO.
/// `announcement` lets a caller that has already told the user which
/// version is available suppress the duplicate line.
///
/// Returns [`UpdateOutcome`] rather than `()` because "no error" and "the
/// binary on disk was replaced" are different facts: `update_prompt`'s
/// re-exec is only sound for the latter.
pub fn run_self_update(yes: bool, announcement: Announcement) -> Result<UpdateOutcome> {
    let confirm_mode = confirm_mode(yes, std::io::stdin().is_terminal());
    if confirm_mode == ConfirmMode::RefuseNonInteractive {
        anyhow::bail!("refusing to self-update non-interactively; pass --yes to proceed");
    }

    let current_version = env!("CARGO_PKG_VERSION");
    let target = self_update::get_target();
    let updater = build_updater(current_version, target)?;

    let latest = updater
        .get_latest_release()
        .context("failed to check the latest rinkaku release")?;
    let is_newer = self_update::version::bump_is_greater(current_version, &latest.version)
        .with_context(|| {
            format!(
                "failed to compare current version v{current_version} with latest v{}",
                latest.version
            )
        })?;
    if !is_newer {
        println!("{BIN_NAME} is already up to date (v{current_version})");
        return Ok(UpdateOutcome::NotUpdated);
    }

    if announcement == Announcement::Print {
        println!(
            "New release found: v{current_version} -> v{}",
            latest.version
        );
    }

    if confirm_mode == ConfirmMode::Prompt {
        print!("Update to v{}? [y/N]: ", latest.version);
        std::io::Write::flush(&mut std::io::stdout())?;
        let mut answer = String::new();
        std::io::stdin().read_line(&mut answer)?;
        if !is_affirmative(&answer) {
            println!("Update cancelled");
            return Ok(UpdateOutcome::NotUpdated);
        }
    }

    // `update()` runs silently under `show_output(false)` (`build_updater`'s
    // doc comment) for as long as its network calls take, which otherwise
    // reads as a hang (ADR 0056).
    let spinner = Spinner::start("Updating...");
    let status = updater.update().context(
        "self-update failed; if this is a permission error, try again with sudo, \
         or update via the package manager you installed rinkaku with \
         (e.g. `brew upgrade` or `cargo install rinkaku`)",
    );
    spinner.finish_and_clear();
    let status = status?;

    if status.updated() {
        println!(
            "Updated {BIN_NAME} from v{current_version} to {}",
            status.version()
        );
        Ok(UpdateOutcome::Updated)
    } else {
        println!("{BIN_NAME} is already up to date (v{current_version})");
        Ok(UpdateOutcome::NotUpdated)
    }
}

/// Checks GitHub for a newer released version than the running binary,
/// returning `Some(latest_version)` only when one is actually newer
/// (`self_update::version::bump_is_greater`). Any failure along the way —
/// network error, GitHub API error, an unparsable version — silently
/// yields `None` rather than propagating an error: this is a background,
/// best-effort check for the TUI's update hint (`rinkaku-tui`'s own
/// `App::notify_update_available`), not a user-initiated action like
/// [`run_self_update`], so there is no confirmation prompt or terminal
/// output to fail loudly against.
pub fn check_update_available() -> Option<String> {
    let current_version = env!("CARGO_PKG_VERSION");
    let target = self_update::get_target();
    let updater = build_updater(current_version, target).ok()?;
    let latest = updater.get_latest_release().ok()?;
    let is_newer = self_update::version::bump_is_greater(current_version, &latest.version).ok()?;
    is_newer.then_some(latest.version)
}

/// Builds the release asset filename for `bin` on `target`, matching the
/// `build-and-publish.yaml` packaging convention:
/// `{bin}-{target}.tar.gz` (e.g. `rinkaku-aarch64-apple-darwin.tar.gz`).
///
/// Passed as `Update::configure()`'s `identifier`, which the `self_update`
/// crate matches against release asset names via a `contains` substring
/// check, not an exact match. Passing the full asset filename (rather than
/// just the target triple) is deliberate: it narrows that substring check
/// down to what is, in practice, an exact match against a single release
/// asset, so a future asset whose name happens to contain this target
/// triple as a substring (e.g. a differently-suffixed archive for the same
/// triple) cannot be picked up by mistake.
fn release_asset_name(bin: &str, target: &str) -> String {
    format!("{bin}-{target}.tar.gz")
}

/// Builds the path to the binary inside the release archive. The archive
/// contains a top-level `{bin}-{target}/` directory (see
/// `build-and-publish.yaml`'s "Package binary" step), so the binary lives
/// one level down from the archive root rather than at its root.
fn archive_bin_path(bin: &str, target: &str) -> String {
    format!("{bin}-{target}/{bin}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use rstest::rstest;

    #[test]
    fn should_build_dot_tar_gz_asset_name_from_bin_and_target() {
        let actual = release_asset_name("rinkaku", "aarch64-apple-darwin");

        assert_eq!("rinkaku-aarch64-apple-darwin.tar.gz", actual);
    }

    #[test]
    fn should_build_nested_archive_path_from_bin_and_target() {
        let actual = archive_bin_path("rinkaku", "x86_64-unknown-linux-gnu");

        assert_eq!("rinkaku-x86_64-unknown-linux-gnu/rinkaku", actual);
    }

    // NOTE: partial assertion (`is_ok()` rather than a fully qualified
    // comparison of the built updater) because the boxed
    // `dyn ReleaseUpdate` this returns has no `Debug`/`PartialEq` to
    // compare against. `is_ok()` is the strongest assertion available: it
    // proves the builder accepts this exact configuration (owner/repo/
    // bin/target/version/identifier/archive path/no_confirm/show_output/
    // show_download_progress) without error. Both `run_self_update` and
    // `check_update_available` share this one builder now
    // (`build_updater`), so this single test covers both call sites'
    // configuration.
    #[test]
    fn should_configure_update_builder_without_error() {
        let target = self_update::get_target();
        let current_version = env!("CARGO_PKG_VERSION");

        // Verifies the builder itself accepts this configuration (no
        // network IO — `build_updater` only validates configuration,
        // `.get_latest_release()`/`.update()` are what would actually hit
        // the network and are intentionally not exercised here per this
        // project's "no mocking external processes" test convention).
        let result = build_updater(current_version, target);

        assert!(
            result.is_ok(),
            "Update builder configuration should succeed"
        );
    }

    #[test]
    fn should_skip_confirmation_when_yes_flag_is_set() {
        let actual = confirm_mode(true, false);

        assert_eq!(ConfirmMode::Skip, actual);
    }

    #[test]
    fn should_prompt_when_yes_flag_is_unset_and_stdin_is_a_tty() {
        let actual = confirm_mode(false, true);

        assert_eq!(ConfirmMode::Prompt, actual);
    }

    #[test]
    fn should_refuse_non_interactively_when_yes_flag_is_unset_and_stdin_is_not_a_tty() {
        let actual = confirm_mode(false, false);

        assert_eq!(ConfirmMode::RefuseNonInteractive, actual);
    }

    #[test]
    fn should_skip_confirmation_when_yes_flag_is_set_even_with_a_tty() {
        // `--yes` always wins regardless of TTY-ness.
        let actual = confirm_mode(true, true);

        assert_eq!(ConfirmMode::Skip, actual);
    }

    #[rstest]
    #[case::should_accept_lowercase_y("y", true)]
    #[case::should_accept_uppercase_y("Y", true)]
    #[case::should_accept_lowercase_yes("yes", true)]
    #[case::should_accept_uppercase_yes("YES", true)]
    #[case::should_accept_mixed_case_yes("Yes", true)]
    #[case::should_accept_y_with_surrounding_whitespace("  y  \n", true)]
    #[case::should_accept_yes_with_surrounding_whitespace("  yes  \n", true)]
    #[case::should_reject_empty_string("", false)]
    #[case::should_reject_whitespace_only_string("   \n", false)]
    #[case::should_reject_n("n", false)]
    #[case::should_reject_no("no", false)]
    #[case::should_reject_arbitrary_text("sure why not", false)]
    #[case::should_reject_y_as_prefix_of_longer_word("yesterday", false)]
    fn should_check_is_affirmative(#[case] answer: &str, #[case] expected: bool) {
        let actual = is_affirmative(answer);

        assert_eq!(expected, actual);
    }
}