rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `rok self-*` — self-update, self-migrate, self-status commands.

use anyhow::Context;
use console::style;
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
use std::path::Path;

const CRATES_IO_API: &str = "https://crates.io/api/v1/crates/rok-cli";
const CONFIG_FILE: &str = ".rok/config.toml";

/// Current installed version, baked at compile time.
fn current_version() -> &'static str {
    env!("CARGO_PKG_VERSION")
}

/// Fetch the latest stable version from crates.io.
fn fetch_latest_version() -> anyhow::Result<String> {
    let mut response = ureq::get(CRATES_IO_API)
        .header("User-Agent", "rok-cli")
        .call()
        .context("Failed to contact crates.io API")?;
    let text = response
        .body_mut()
        .read_to_string()
        .context("Failed to read crates.io response")?;
    let resp: serde_json::Value =
        serde_json::from_str(&text).context("Failed to parse crates.io response")?;

    let crate_data = &resp["crate"];
    let latest = crate_data["max_stable_version"]
        .as_str()
        .or_else(|| crate_data["max_version"].as_str())
        .unwrap_or("unknown");
    Ok(latest.to_string())
}

/// Compare two semver versions, returning `None` on parse failure.
fn compare_versions(current: &str, latest: &str) -> Option<Ordering> {
    let cur = semver::Version::parse(current).ok()?;
    let lat = semver::Version::parse(latest).ok()?;
    Some(cur.cmp(&lat))
}

// ── Config file handling for self-migrate ─────────────────────────────────

#[derive(Deserialize, Serialize, Default)]
struct RokConfig {
    version: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    self_update: Option<SelfUpdateConfig>,
}

#[derive(Deserialize, Serialize, Default)]
struct SelfUpdateConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    auto_check: Option<bool>,
}

fn read_config() -> anyhow::Result<RokConfig> {
    let path = Path::new(CONFIG_FILE);
    if !path.exists() {
        return Ok(RokConfig::default());
    }
    let content =
        std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", CONFIG_FILE))?;
    toml::from_str(&content).with_context(|| format!("Failed to parse {}", CONFIG_FILE))
}

fn write_config(config: &RokConfig) -> anyhow::Result<()> {
    let content = toml::to_string_pretty(config).context("Failed to serialize config")?;
    std::fs::write(CONFIG_FILE, content).with_context(|| format!("Failed to write {}", CONFIG_FILE))
}

// ── Public API ────────────────────────────────────────────────────────────

/// `rok self-status` — show installed version, latest available, and migration status.
pub fn run_check() -> anyhow::Result<()> {
    let current = current_version();
    println!(
        "  {} Installed version: {}",
        style("").cyan(),
        style(current).bold()
    );

    let latest = match fetch_latest_version() {
        Ok(v) => v,
        Err(e) => {
            println!(
                "  {} Could not check latest version: {}",
                style("").red(),
                e
            );
            return Ok(());
        }
    };

    println!(
        "  {} Latest available:  {}",
        style("").cyan(),
        style(&latest).bold()
    );

    match compare_versions(current, &latest) {
        Some(Ordering::Less) => {
            println!(
                "  {} Update available! Run `rok self-update`",
                style("").yellow()
            );
        }
        Some(Ordering::Equal) | Some(Ordering::Greater) => {
            println!("  {} You're up to date.", style("").green());
        }
        None => {
            println!(
                "  {} Could not compare versions (non-semver format).",
                style("").yellow()
            );
        }
    }

    // Migration status
    let config = read_config()?;
    if let Some(cfg_ver) = &config.version {
        println!(
            "  {} Config version:    {}",
            style("").cyan(),
            style(cfg_ver).bold()
        );
        if let Some(Ordering::Less) = compare_versions(cfg_ver, current) {
            println!(
                "  {} Config is behind CLI — run `rok self-migrate`",
                style("").yellow()
            );
        }
    } else {
        println!(
            "  {} Config version:    {}",
            style("").cyan(),
            style("not set").dim()
        );
    }

    println!(
        "  {} Config file:       {}",
        style("").cyan(),
        style(CONFIG_FILE).dim()
    );

    Ok(())
}

/// `rok self-update` — update to latest or a specific version.
pub fn run_update(version: Option<&str>, force: bool, dry_run: bool) -> anyhow::Result<()> {
    let current = current_version();
    let target = match version {
        Some(ver) => ver.to_string(),
        None => fetch_latest_version()?,
    };

    if !force {
        if let Some(Ordering::Less | Ordering::Equal) = compare_versions(&target, current) {
            println!(
                "  {} Already at version {} (or newer). Use --force to re-install.",
                style("").green(),
                current
            );
            return Ok(());
        }
    }

    println!(
        "  {} Updating rok-cli: {}{}",
        style("~").yellow(),
        style(current).bold(),
        style(&target).bold()
    );

    if dry_run {
        println!(
            "  {} Dry-run — would run: cargo install rok-cli --version {}",
            style("").cyan(),
            &target
        );
        return Ok(());
    }

    let status = std::process::Command::new("cargo")
        .args(["install", "rok-cli", "--version", &target])
        .status()
        .context("Failed to execute cargo install")?;

    if status.success() {
        println!(
            "  {} rok-cli updated to version {}",
            style("").green(),
            &target
        );
    } else {
        anyhow::bail!("cargo install exited with code {:?}", status.code());
    }

    Ok(())
}

/// `rok self-migrate` — migrate configuration and project files between versions.
pub fn run_migrate(from: Option<&str>, to: Option<&str>, dry_run: bool) -> anyhow::Result<()> {
    let current = current_version();
    let config_from = from
        .map(|s| s.to_string())
        .or_else(|| read_config().ok().and_then(|c| c.version))
        .unwrap_or_else(|| current.to_string());
    let config_to = to.unwrap_or(current).to_string();

    println!(
        "  {} Migrating config: {}{}",
        style("~").yellow(),
        style(&config_from).bold(),
        style(&config_to).bold()
    );

    if dry_run {
        println!("  {} Dry-run — no changes written.", style("").cyan());
        println!(
            "    Would run migration steps for: {}{}",
            &config_from, &config_to
        );
        return Ok(());
    }

    // ── Migration steps ──────────────────────────────────────────────────
    let from_semver = semver::Version::parse(&config_from).ok();
    let to_semver = semver::Version::parse(&config_to).ok();

    let mut config = read_config()?;

    if let (Some(from_v), Some(_to_v)) = (&from_semver, &to_semver) {
        // 0.1.x → 0.2.0 migration: add [self_update] section
        let v0_1_0 = semver::Version::new(0, 1, 0);
        let v0_2_0 = semver::Version::new(0, 2, 0);
        if *from_v >= v0_1_0 && *from_v < v0_2_0 && *_to_v >= v0_2_0 {
            println!(
                "  {} Applying 0.1.x → 0.2.0 migration: add [self_update] section",
                style("~").yellow()
            );
            if config.self_update.is_none() {
                config.self_update = Some(SelfUpdateConfig {
                    auto_check: Some(true),
                });
            }
        }
    }

    config.version = Some(config_to.clone());
    write_config(&config)?;

    println!(
        "  {} Config migrated to version {}",
        style("").green(),
        &config_to
    );

    Ok(())
}