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";
fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
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())
}
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))
}
#[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))
}
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()
);
}
}
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(())
}
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(())
}
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(());
}
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) {
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(())
}