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 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 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 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
137fn 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
173fn 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 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}