Skip to main content

invoice_cli/commands/
update.rs

1use std::process::Command;
2
3use crate::error::{AppError, Result};
4use crate::output::{print_success, Ctx};
5
6const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates/invoice-cli";
7
8pub fn run(ctx: Ctx, check: bool) -> Result<()> {
9    let current = env!("CARGO_PKG_VERSION");
10    let latest = match fetch_latest_version() {
11        Ok(v) => v,
12        Err(e) => {
13            // Crate not yet published, or crates.io unreachable. Not fatal for
14            // --check — report current and move on.
15            let payload = serde_json::json!({
16                "current": current,
17                "latest": null,
18                "update_available": false,
19                "note": format!("could not query crates.io: {e}. You may not be on a published release yet."),
20            });
21            print_success(ctx, &payload, |p| {
22                println!("current: {}", p["current"].as_str().unwrap_or("?"));
23                println!("latest:  unknown ({})", p["note"].as_str().unwrap_or(""));
24            });
25            return Ok(());
26        }
27    };
28
29    let is_newer = version_newer_than(&latest, current);
30
31    if check {
32        let payload = serde_json::json!({
33            "current": current,
34            "latest": latest,
35            "update_available": is_newer,
36        });
37        print_success(ctx, &payload, |p| {
38            println!("current: {}", p["current"].as_str().unwrap_or("?"));
39            println!("latest:  {}", p["latest"].as_str().unwrap_or("?"));
40            if is_newer {
41                println!("update available — run `invoice update` to install");
42            } else {
43                println!("up to date");
44            }
45        });
46        return Ok(());
47    }
48
49    if !is_newer {
50        let payload = serde_json::json!({
51            "current": current,
52            "latest": latest,
53            "updated": false,
54            "note": "already on latest",
55        });
56        print_success(ctx, &payload, |_| println!("already on latest ({})", current));
57        return Ok(());
58    }
59
60    // Detect install method and use the right upgrade command.
61    let cmd = install_upgrade_command();
62    eprintln!("upgrading {current} → {latest} via: {}", cmd.join(" "));
63    let status = Command::new(&cmd[0])
64        .args(&cmd[1..])
65        .status()
66        .map_err(|e| AppError::Other(format!("failed to launch upgrader: {e}")))?;
67
68    if !status.success() {
69        return Err(AppError::Other(format!(
70            "upgrade command exited with status {}",
71            status.code().unwrap_or(-1)
72        )));
73    }
74
75    let payload = serde_json::json!({
76        "current": current,
77        "latest": latest,
78        "updated": true,
79        "method": cmd.join(" "),
80    });
81    print_success(ctx, &payload, |_| {
82        println!("upgraded to {latest}. verify with: invoice --version")
83    });
84    Ok(())
85}
86
87fn fetch_latest_version() -> Result<String> {
88    let out = Command::new("curl")
89        .args([
90            "-sSL",
91            "-H",
92            "User-Agent: invoice-cli",
93            "-H",
94            "Accept: application/json",
95            CRATES_IO_URL,
96        ])
97        .output()
98        .map_err(|e| AppError::Other(format!("curl not available: {e}")))?;
99    if !out.status.success() {
100        return Err(AppError::Other(format!(
101            "crates.io query failed (exit {})",
102            out.status.code().unwrap_or(-1)
103        )));
104    }
105    let body: serde_json::Value = serde_json::from_slice(&out.stdout)
106        .map_err(|e| AppError::Other(format!("bad crates.io response: {e}")))?;
107    // crates.io 404 shape: { "errors": [{ "detail": "…" }] }
108    if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
109        let detail = errors
110            .first()
111            .and_then(|e| e.get("detail"))
112            .and_then(|d| d.as_str())
113            .unwrap_or("unknown");
114        return Err(AppError::Other(format!("crates.io: {detail}")));
115    }
116    body.get("crate")
117        .and_then(|c| c.get("max_stable_version"))
118        .and_then(|v| v.as_str())
119        .map(|s| s.to_string())
120        .ok_or_else(|| AppError::Other("crates.io response missing max_stable_version".into()))
121}
122
123/// Semver-aware comparison: is `a` strictly newer than `b`? Falls back to
124/// string compare on parse failure (pessimistic: returns false).
125fn version_newer_than(a: &str, b: &str) -> bool {
126    let pa = parse_version(a);
127    let pb = parse_version(b);
128    match (pa, pb) {
129        (Some(a), Some(b)) => a > b,
130        _ => false,
131    }
132}
133
134fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
135    let core = v.split(['-', '+']).next()?;
136    let mut parts = core.split('.');
137    Some((
138        parts.next()?.parse().ok()?,
139        parts.next()?.parse().ok()?,
140        parts.next().unwrap_or("0").parse().unwrap_or(0),
141    ))
142}
143
144/// Pick the right upgrader. Prefer Homebrew on macOS when `brew` is on PATH
145/// and the binary lives under a brew prefix; otherwise fall back to cargo.
146fn install_upgrade_command() -> Vec<String> {
147    if cfg!(target_os = "macos") && running_under_brew() {
148        return vec![
149            "brew".into(),
150            "upgrade".into(),
151            "199-biotechnologies/tap/invoice".into(),
152        ];
153    }
154    vec![
155        "cargo".into(),
156        "install".into(),
157        "--force".into(),
158        "invoice-cli".into(),
159    ]
160}
161
162fn running_under_brew() -> bool {
163    let exe = match std::env::current_exe() {
164        Ok(p) => p,
165        Err(_) => return false,
166    };
167    let s = exe.to_string_lossy();
168    s.contains("/homebrew/") || s.contains("/Cellar/") || s.contains("/opt/homebrew/")
169}