offline-cli 0.3.3

The Offline Protocol developer CLI
//! `offline update`: check the npm registry for a newer CLI version.
//!
//! The CLI is distributed as `@offline-protocol/cli` on npm, so the npm `latest`
//! dist-tag is the source of truth for "is there a newer release". The source
//! repository is not public, so GitHub Releases cannot be queried anonymously.

use anyhow::{Context, Result};
use clap::Args;
use serde_json::json;

use crate::output;

/// npm package name the CLI ships under.
pub const NPM_PACKAGE: &str = "@offline-protocol/cli";
/// Default public npm registry.
const DEFAULT_REGISTRY: &str = "https://registry.npmjs.org";
/// Override for mirrors and tests (for example a private npm proxy).
const REGISTRY_ENV: &str = "OFFLINE_NPM_REGISTRY";

#[derive(Args)]
pub struct UpdateArgs {
    /// Output JSON
    #[arg(long)]
    pub json: bool,
}

pub fn run(args: UpdateArgs) -> Result<()> {
    let current = env!("CARGO_PKG_VERSION");
    let registry = registry_base();
    let latest = fetch_latest_version(&registry);

    let (latest, update_available, fetch_error) = match &latest {
        Ok(v) => (Some(v.clone()), is_newer(v, current), None),
        Err(e) => (None, false, Some(format!("{e:#}"))),
    };

    if args.json {
        return output::json(&json!({
            "current": current,
            "latest": latest,
            "updateAvailable": update_available,
            "package": NPM_PACKAGE,
            "installCommand": format!("npm install -g {NPM_PACKAGE}@latest"),
            "npxCommand": format!("npx {NPM_PACKAGE}@latest"),
            "error": fetch_error,
        }));
    }

    println!("current version: {current}");
    match (latest, fetch_error) {
        (Some(v), _) if update_available => {
            output::warn(&format!("update available: {v}"));
            print_upgrade_commands();
        }
        (Some(v), _) => output::success(&format!("you are on the latest release ({v})")),
        (None, err) => {
            let detail = err.unwrap_or_else(|| "unknown error".to_string());
            output::warn(&format!("could not check for updates: {detail}"));
            output::note("Check your network connection, or upgrade directly:");
            print_upgrade_commands();
        }
    }
    Ok(())
}

fn print_upgrade_commands() {
    output::heading("Update with");
    println!("  npm install -g {NPM_PACKAGE}@latest");
    println!("  # or run the latest release without installing:");
    println!("  npx {NPM_PACKAGE}@latest --version");
}

fn registry_base() -> String {
    std::env::var(REGISTRY_ENV)
        .ok()
        .map(|v| v.trim().trim_end_matches('/').to_string())
        .filter(|v| !v.is_empty())
        .unwrap_or_else(|| DEFAULT_REGISTRY.to_string())
}

/// URL of the dist-tags document for the CLI package (a few bytes of JSON).
fn dist_tags_url(registry: &str) -> String {
    // Scoped names keep the `@` but encode the `/` for the registry API.
    let encoded = NPM_PACKAGE.replace('/', "%2f");
    format!("{registry}/-/package/{encoded}/dist-tags")
}

fn fetch_latest_version(registry: &str) -> Result<String> {
    let url = dist_tags_url(registry);
    let agent = ureq::Agent::new_with_config(
        ureq::Agent::config_builder()
            .timeout_global(Some(std::time::Duration::from_secs(8)))
            .user_agent(concat!("offline-cli/", env!("CARGO_PKG_VERSION")))
            .build(),
    );
    let mut resp = agent
        .get(&url)
        .header("Accept", "application/json")
        .call()
        .map_err(|e| match e {
            ureq::Error::StatusCode(code) => {
                anyhow::anyhow!("npm registry returned HTTP {code} for {NPM_PACKAGE}")
            }
            other => anyhow::anyhow!("npm registry request failed: {other}"),
        })?;

    let value: serde_json::Value = resp
        .body_mut()
        .read_json()
        .context("decoding npm dist-tags JSON")?;
    parse_latest(&value)
}

fn parse_latest(value: &serde_json::Value) -> Result<String> {
    value
        .get("latest")
        .and_then(|t| t.as_str())
        .map(|t| t.trim().trim_start_matches('v').to_string())
        .filter(|t| !t.is_empty())
        .ok_or_else(|| anyhow::anyhow!("npm registry response has no `latest` dist-tag"))
}

/// Compare semver-style versions (`x.y.z` with an optional `-prerelease`).
/// A release is newer than a prerelease of the same `x.y.z`.
fn is_newer(candidate: &str, current: &str) -> bool {
    fn split(v: &str) -> (Vec<u64>, bool) {
        let v = v.trim().trim_start_matches('v');
        let v = v.split('+').next().unwrap_or(v);
        let (core, pre) = match v.split_once('-') {
            Some((core, _)) => (core, true),
            None => (v, false),
        };
        let nums = core
            .split('.')
            .map(|p| p.parse::<u64>().unwrap_or(0))
            .collect();
        (nums, pre)
    }
    let (cand, cand_pre) = split(candidate);
    let (cur, cur_pre) = split(current);
    match cand.cmp(&cur) {
        std::cmp::Ordering::Greater => true,
        std::cmp::Ordering::Less => false,
        std::cmp::Ordering::Equal => cur_pre && !cand_pre,
    }
}

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

    #[test]
    fn version_comparison() {
        assert!(is_newer("0.2.0", "0.1.0"));
        assert!(is_newer("1.0.0", "0.9.9"));
        assert!(is_newer("0.2.10", "0.2.9"));
        assert!(!is_newer("0.1.0", "0.1.0"));
        assert!(!is_newer("0.0.9", "0.1.0"));
        assert!(is_newer("0.3.0", "0.3.0-beta.1"));
        assert!(!is_newer("0.3.0-beta.1", "0.3.0"));
        assert!(!is_newer("0.2.3", "0.3.0-beta.1"));
    }

    #[test]
    fn dist_tags_url_encodes_scope() {
        assert_eq!(
            dist_tags_url("https://registry.npmjs.org"),
            "https://registry.npmjs.org/-/package/@offline-protocol%2fcli/dist-tags"
        );
    }

    #[test]
    fn parses_latest_tag() {
        let v = json!({"latest": "0.2.3", "beta": "0.3.0-beta.1"});
        assert_eq!(parse_latest(&v).unwrap(), "0.2.3");
        assert!(parse_latest(&json!({"beta": "1.0.0-beta.1"})).is_err());
    }

    #[test]
    fn fetches_from_registry() {
        let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
        let port = server.server_addr().to_ip().unwrap().port();
        let handle = std::thread::spawn(move || {
            let req = server.recv().unwrap();
            let url = req.url().to_string();
            let resp = tiny_http::Response::from_string(r#"{"latest":"9.9.9"}"#).with_header(
                "Content-Type: application/json"
                    .parse::<tiny_http::Header>()
                    .unwrap(),
            );
            req.respond(resp).unwrap();
            url
        });
        let latest = fetch_latest_version(&format!("http://127.0.0.1:{port}")).unwrap();
        assert_eq!(latest, "9.9.9");
        assert_eq!(
            handle.join().unwrap(),
            "/-/package/@offline-protocol%2fcli/dist-tags"
        );
    }

    #[test]
    fn registry_error_is_reported_without_repo_hint() {
        let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
        let port = server.server_addr().to_ip().unwrap().port();
        std::thread::spawn(move || {
            let req = server.recv().unwrap();
            req.respond(tiny_http::Response::from_string("{}").with_status_code(503))
                .unwrap();
        });
        let err = fetch_latest_version(&format!("http://127.0.0.1:{port}"))
            .unwrap_err()
            .to_string();
        assert!(err.contains("HTTP 503"), "{err}");
        assert!(!err.to_lowercase().contains("github"), "{err}");
    }
}