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, |_| {
57            println!("already on latest ({})", current)
58        });
59        return Ok(());
60    }
61
62    // Detect install method and use the right upgrade command.
63    let cmd = install_upgrade_command();
64    eprintln!("upgrading {current} → {latest} via: {}", cmd.join(" "));
65    let status = Command::new(&cmd[0])
66        .args(&cmd[1..])
67        .status()
68        .map_err(|e| AppError::Other(format!("failed to launch upgrader: {e}")))?;
69
70    if !status.success() {
71        return Err(AppError::Other(format!(
72            "upgrade command exited with status {}",
73            status.code().unwrap_or(-1)
74        )));
75    }
76
77    let payload = serde_json::json!({
78        "current": current,
79        "latest": latest,
80        "updated": true,
81        "method": cmd.join(" "),
82    });
83    print_success(ctx, &payload, |_| {
84        println!("upgraded to {latest}. verify with: invoice --version")
85    });
86    Ok(())
87}
88
89fn fetch_latest_version() -> Result<String> {
90    let out = Command::new("curl")
91        .args([
92            "-sSL",
93            "-H",
94            "User-Agent: invoice-cli",
95            "-H",
96            "Accept: application/json",
97            CRATES_IO_URL,
98        ])
99        .output()
100        .map_err(|e| AppError::Other(format!("curl not available: {e}")))?;
101    if !out.status.success() {
102        return Err(AppError::Other(format!(
103            "crates.io query failed (exit {})",
104            out.status.code().unwrap_or(-1)
105        )));
106    }
107    let body: serde_json::Value = serde_json::from_slice(&out.stdout)
108        .map_err(|e| AppError::Other(format!("bad crates.io response: {e}")))?;
109    // crates.io 404 shape: { "errors": [{ "detail": "…" }] }
110    if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
111        let detail = errors
112            .first()
113            .and_then(|e| e.get("detail"))
114            .and_then(|d| d.as_str())
115            .unwrap_or("unknown");
116        return Err(AppError::Other(format!("crates.io: {detail}")));
117    }
118    body.get("crate")
119        .and_then(|c| c.get("max_stable_version"))
120        .and_then(|v| v.as_str())
121        .map(|s| s.to_string())
122        .ok_or_else(|| AppError::Other("crates.io response missing max_stable_version".into()))
123}
124
125/// Semver-aware comparison: is `a` strictly newer than `b`? Falls back to
126/// string compare on parse failure (pessimistic: returns false).
127fn version_newer_than(a: &str, b: &str) -> bool {
128    let pa = parse_version(a);
129    let pb = parse_version(b);
130    match (pa, pb) {
131        (Some(a), Some(b)) => a > b,
132        _ => false,
133    }
134}
135
136fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
137    let core = v.split(['-', '+']).next()?;
138    let mut parts = core.split('.');
139    Some((
140        parts.next()?.parse().ok()?,
141        parts.next()?.parse().ok()?,
142        parts.next().unwrap_or("0").parse().unwrap_or(0),
143    ))
144}
145
146/// Pick the right upgrader. Prefer Homebrew on macOS when `brew` is on PATH
147/// and the binary lives under a brew prefix; otherwise fall back to cargo.
148fn install_upgrade_command() -> Vec<String> {
149    if cfg!(target_os = "macos") && running_under_brew() {
150        return vec![
151            "brew".into(),
152            "upgrade".into(),
153            "199-biotechnologies/tap/invoice".into(),
154        ];
155    }
156    vec![
157        "cargo".into(),
158        "install".into(),
159        "--force".into(),
160        "invoice-cli".into(),
161    ]
162}
163
164fn running_under_brew() -> bool {
165    let exe = match std::env::current_exe() {
166        Ok(p) => p,
167        Err(_) => return false,
168    };
169    let s = exe.to_string_lossy();
170    s.contains("/homebrew/") || s.contains("/Cellar/") || s.contains("/opt/homebrew/")
171}