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.display());
65    let mut child = Command::new(&cmd.program);
66    child.args(&cmd.args);
67    for (key, value) in &cmd.env {
68        child.env(key, value);
69    }
70    let status = child
71        .status()
72        .map_err(|e| AppError::Other(format!("failed to launch upgrader: {e}")))?;
73
74    if !status.success() {
75        return Err(AppError::Other(format!(
76            "upgrade command exited with status {}",
77            status.code().unwrap_or(-1)
78        )));
79    }
80
81    let installed = installed_invoice_version()?;
82    if version_newer_than(&latest, &installed) {
83        return Err(AppError::Other(format!(
84            "upgrade completed but `invoice --version` reports {installed}, expected {latest}"
85        )));
86    }
87
88    let payload = serde_json::json!({
89        "current": current,
90        "latest": latest,
91        "installed": installed,
92        "updated": true,
93        "method": cmd.display(),
94    });
95    print_success(ctx, &payload, |_| {
96        println!("upgraded to {latest}. verify with: invoice --version")
97    });
98    Ok(())
99}
100
101fn fetch_latest_version() -> Result<String> {
102    let out = Command::new("curl")
103        .args([
104            "-sSL",
105            "-H",
106            "User-Agent: invoice-cli",
107            "-H",
108            "Accept: application/json",
109            CRATES_IO_URL,
110        ])
111        .output()
112        .map_err(|e| AppError::Other(format!("curl not available: {e}")))?;
113    if !out.status.success() {
114        return Err(AppError::Other(format!(
115            "crates.io query failed (exit {})",
116            out.status.code().unwrap_or(-1)
117        )));
118    }
119    let body: serde_json::Value = serde_json::from_slice(&out.stdout)
120        .map_err(|e| AppError::Other(format!("bad crates.io response: {e}")))?;
121    // crates.io 404 shape: { "errors": [{ "detail": "…" }] }
122    if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
123        let detail = errors
124            .first()
125            .and_then(|e| e.get("detail"))
126            .and_then(|d| d.as_str())
127            .unwrap_or("unknown");
128        return Err(AppError::Other(format!("crates.io: {detail}")));
129    }
130    body.get("crate")
131        .and_then(|c| c.get("max_stable_version"))
132        .and_then(|v| v.as_str())
133        .map(|s| s.to_string())
134        .ok_or_else(|| AppError::Other("crates.io response missing max_stable_version".into()))
135}
136
137/// Semver-aware comparison: is `a` strictly newer than `b`? Falls back to
138/// string compare on parse failure (pessimistic: returns false).
139fn version_newer_than(a: &str, b: &str) -> bool {
140    let pa = parse_version(a);
141    let pb = parse_version(b);
142    match (pa, pb) {
143        (Some(a), Some(b)) => a > b,
144        _ => false,
145    }
146}
147
148fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
149    let core = v.split(['-', '+']).next()?;
150    let mut parts = core.split('.');
151    Some((
152        parts.next()?.parse().ok()?,
153        parts.next()?.parse().ok()?,
154        parts.next().unwrap_or("0").parse().unwrap_or(0),
155    ))
156}
157
158struct UpgradeCommand {
159    program: String,
160    args: Vec<String>,
161    env: Vec<(String, String)>,
162}
163
164impl UpgradeCommand {
165    fn display(&self) -> String {
166        let mut parts = Vec::with_capacity(1 + self.args.len());
167        parts.push(self.program.as_str());
168        parts.extend(self.args.iter().map(String::as_str));
169        parts.join(" ")
170    }
171}
172
173/// Pick the right upgrader. Prefer Homebrew on macOS when `brew` is on PATH
174/// and the binary lives under a brew prefix; otherwise fall back to cargo.
175fn install_upgrade_command() -> Result<UpgradeCommand> {
176    if cfg!(target_os = "macos") && running_under_brew() {
177        refresh_homebrew_tap()?;
178        return Ok(UpgradeCommand {
179            program: "brew".into(),
180            args: vec!["upgrade".into(), "199-biotechnologies/tap/invoice".into()],
181            // The tap was refreshed directly above. Avoid failing because an
182            // unrelated local tap is broken during Homebrew auto-update.
183            env: vec![("HOMEBREW_NO_AUTO_UPDATE".into(), "1".into())],
184        });
185    }
186    Ok(UpgradeCommand {
187        program: "cargo".into(),
188        args: vec![
189            "install".into(),
190            "--force".into(),
191            "--locked".into(),
192            "invoice-cli".into(),
193        ],
194        env: Vec::new(),
195    })
196}
197
198fn refresh_homebrew_tap() -> Result<()> {
199    let repo = Command::new("brew")
200        .args(["--repo", "199-biotechnologies/tap"])
201        .output()
202        .map_err(|e| AppError::Other(format!("failed to locate Homebrew tap: {e}")))?;
203    if !repo.status.success() {
204        return Err(AppError::Other(format!(
205            "failed to locate Homebrew tap (exit {})",
206            repo.status.code().unwrap_or(-1)
207        )));
208    }
209    let path = String::from_utf8_lossy(&repo.stdout).trim().to_string();
210    if path.is_empty() {
211        return Err(AppError::Other(
212            "brew --repo returned an empty tap path".into(),
213        ));
214    }
215
216    eprintln!("refreshing Homebrew tap: git -C {path} pull --ff-only");
217    let status = Command::new("git")
218        .args(["-C", &path, "pull", "--ff-only"])
219        .status()
220        .map_err(|e| AppError::Other(format!("failed to refresh Homebrew tap: {e}")))?;
221    if !status.success() {
222        return Err(AppError::Other(format!(
223            "failed to refresh Homebrew tap (exit {})",
224            status.code().unwrap_or(-1)
225        )));
226    }
227    Ok(())
228}
229
230fn installed_invoice_version() -> Result<String> {
231    let out = Command::new("invoice")
232        .arg("--version")
233        .output()
234        .map_err(|e| AppError::Other(format!("failed to verify installed invoice: {e}")))?;
235    if !out.status.success() {
236        return Err(AppError::Other(format!(
237            "invoice --version failed after upgrade (exit {})",
238            out.status.code().unwrap_or(-1)
239        )));
240    }
241    let stdout = String::from_utf8_lossy(&out.stdout);
242    parse_invoice_version(&stdout)
243        .ok_or_else(|| AppError::Other(format!("could not parse invoice version from: {stdout}")))
244}
245
246fn parse_invoice_version(output: &str) -> Option<String> {
247    output
248        .split_whitespace()
249        .find(|part| parse_version(part).is_some())
250        .map(ToOwned::to_owned)
251}
252
253fn running_under_brew() -> bool {
254    let exe = match std::env::current_exe() {
255        Ok(p) => p,
256        Err(_) => return false,
257    };
258    let s = exe.to_string_lossy();
259    s.contains("/homebrew/") || s.contains("/Cellar/") || s.contains("/opt/homebrew/")
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn parses_invoice_version_output() {
268        assert_eq!(
269            parse_invoice_version("invoice 0.5.9\n").as_deref(),
270            Some("0.5.9")
271        );
272    }
273
274    #[test]
275    fn compares_semver_versions() {
276        assert!(version_newer_than("0.5.10", "0.5.9"));
277        assert!(!version_newer_than("0.5.9", "0.5.9"));
278        assert!(!version_newer_than("0.5.9", "0.5.10"));
279    }
280}