1use std::process::Command;
6
7use crate::error::{AppError, Result};
8use crate::output::{print_success, Ctx};
9
10const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates/contract-cli";
11const BREW_FORMULA: &str = "paperfoot/tap/contract";
12const RELEASE_URL: &str = "https://github.com/paperfoot/contract-cli/releases";
13
14#[derive(serde::Serialize)]
15struct UpdateReport {
16 current_version: String,
17 latest_version: Option<String>,
18 status: &'static str,
19 install_source: &'static str,
20 update_mode: &'static str,
21 upgrade_command: String,
22 release_url: &'static str,
23 requires_skill_reinstall: bool,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 note: Option<String>,
26}
27
28pub fn run(ctx: Ctx, check: bool) -> Result<()> {
29 let current = env!("CARGO_PKG_VERSION").to_string();
30 let (install_source, update_mode, upgrade_command) = if running_under_brew() {
31 (
32 "homebrew",
33 "package_manager",
34 format!("brew upgrade {BREW_FORMULA}"),
35 )
36 } else {
37 (
38 "cargo",
39 "package_manager",
40 "cargo install --locked --force contract-cli".to_string(),
41 )
42 };
43
44 if let Ok(cfg) = crate::config::load() {
45 if !cfg.self_update {
46 let report = UpdateReport {
47 current_version: current,
48 latest_version: None,
49 status: "disabled",
50 install_source,
51 update_mode: "disabled",
52 upgrade_command,
53 release_url: RELEASE_URL,
54 requires_skill_reinstall: false,
55 note: Some("self_update = false in config; upgrade via the package manager".into()),
56 };
57 print_success(ctx, &report, |r| {
58 println!("updates disabled by config. Upgrade manually: {}", r.upgrade_command)
59 });
60 return Ok(());
61 }
62 }
63
64 let latest = match fetch_latest_version() {
65 Ok(v) => v,
66 Err(e) => {
67 let report = UpdateReport {
70 current_version: current,
71 latest_version: None,
72 status: "up_to_date",
73 install_source,
74 update_mode,
75 upgrade_command,
76 release_url: RELEASE_URL,
77 requires_skill_reinstall: false,
78 note: Some(format!("could not query crates.io: {e}")),
79 };
80 print_success(ctx, &report, |r| {
81 println!(
82 "current: {} — latest unknown ({})",
83 r.current_version,
84 r.note.as_deref().unwrap_or("")
85 )
86 });
87 return Ok(());
88 }
89 };
90
91 let is_newer = version_newer_than(&latest, ¤t);
92 let status = if is_newer { "update_available" } else { "up_to_date" };
93
94 if check || !is_newer {
95 let report = UpdateReport {
96 current_version: current,
97 latest_version: Some(latest),
98 status,
99 install_source,
100 update_mode,
101 upgrade_command,
102 release_url: RELEASE_URL,
103 requires_skill_reinstall: is_newer,
104 note: None,
105 };
106 print_success(ctx, &report, |r| {
107 println!("current: {}", r.current_version);
108 println!("latest: {}", r.latest_version.as_deref().unwrap_or("?"));
109 if r.status == "update_available" {
110 println!("update available — run: {}", r.upgrade_command);
111 } else {
112 println!("up to date");
113 }
114 });
115 return Ok(());
116 }
117
118 eprintln!("upgrading {current} → {latest} via: {upgrade_command}");
120 let ok = if install_source == "homebrew" {
121 run_cmd("brew", &["upgrade", BREW_FORMULA])?
122 } else {
123 run_cmd(
124 "cargo",
125 &["install", "--locked", "--force", "contract-cli"],
126 )?
127 };
128 if !ok {
129 return Err(AppError::Transient(
130 "upgrade command failed — run it manually to see the full output".into(),
131 ));
132 }
133 let report = UpdateReport {
134 current_version: current,
135 latest_version: Some(latest.clone()),
136 status: "updated",
137 install_source,
138 update_mode,
139 upgrade_command,
140 release_url: RELEASE_URL,
141 requires_skill_reinstall: true,
142 note: Some("run `contract skill install` to refresh the installed skill".into()),
143 };
144 print_success(ctx, &report, |_| {
145 println!("upgraded to {latest}. Refresh the skill: contract skill install");
146 });
147 Ok(())
148}
149
150fn run_cmd(program: &str, args: &[&str]) -> Result<bool> {
151 let status = Command::new(program)
152 .args(args)
153 .env("HOMEBREW_NO_AUTO_UPDATE", "1")
154 .status()
155 .map_err(|e| AppError::Transient(format!("failed to launch {program}: {e}")))?;
156 Ok(status.success())
157}
158
159fn fetch_latest_version() -> Result<String> {
160 let out = Command::new("curl")
161 .args([
162 "-sSL",
163 "--max-time",
164 "10",
165 "-H",
166 "User-Agent: contract-cli",
167 "-H",
168 "Accept: application/json",
169 CRATES_IO_URL,
170 ])
171 .output()
172 .map_err(|e| AppError::Transient(format!("curl not available: {e}")))?;
173 if !out.status.success() {
174 return Err(AppError::Transient(format!(
175 "crates.io query failed (exit {})",
176 out.status.code().unwrap_or(-1)
177 )));
178 }
179 let body: serde_json::Value = serde_json::from_slice(&out.stdout)
180 .map_err(|e| AppError::Transient(format!("bad crates.io response: {e}")))?;
181 if let Some(errors) = body.get("errors").and_then(|e| e.as_array()) {
182 let detail = errors
183 .first()
184 .and_then(|e| e.get("detail"))
185 .and_then(|d| d.as_str())
186 .unwrap_or("unknown");
187 return Err(AppError::Transient(format!("crates.io: {detail}")));
188 }
189 body.get("crate")
190 .and_then(|c| c.get("max_stable_version"))
191 .and_then(|v| v.as_str())
192 .map(|s| s.to_string())
193 .ok_or_else(|| AppError::Transient("crates.io response missing max_stable_version".into()))
194}
195
196fn version_newer_than(a: &str, b: &str) -> bool {
197 match (parse_version(a), parse_version(b)) {
198 (Some(a), Some(b)) => a > b,
199 _ => false,
200 }
201}
202
203fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
204 let core = v.split(['-', '+']).next()?;
205 let mut parts = core.split('.');
206 Some((
207 parts.next()?.parse().ok()?,
208 parts.next()?.parse().ok()?,
209 parts.next().unwrap_or("0").parse().unwrap_or(0),
210 ))
211}
212
213fn running_under_brew() -> bool {
214 std::env::current_exe()
215 .map(|p| {
216 let s = p.to_string_lossy().to_string();
217 s.contains("/homebrew/") || s.contains("/Cellar/") || s.contains("/opt/homebrew/")
218 })
219 .unwrap_or(false)
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn compares_semver_versions() {
228 assert!(version_newer_than("0.2.0", "0.1.0"));
229 assert!(!version_newer_than("0.1.0", "0.1.0"));
230 assert!(!version_newer_than("0.1.0", "0.2.0"));
231 }
232}