1use crate::cli::ui::{self, status_line, tip, Loader};
4use colored::Colorize;
5use semver::Version;
6use serde::Deserialize;
7use serde_json::json;
8use std::process::Command;
9
10const CRATE_PAGE_URL: &str = "https://crates.io/crates/xbp";
11const CRATES_IO_API_URL: &str = "https://crates.io/api/v1/crates";
12const DEFAULT_CRATE_NAME: &str = "xbp";
13const USER_AGENT: &str = concat!(
14 "xbp/",
15 env!("CARGO_PKG_VERSION"),
16 " (+https://github.com/xylex-group/xbp)"
17);
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum UpdateStatus {
21 UpToDate,
22 UpdateAvailable,
23 LocalNewer,
24 Unknown,
25}
26
27impl UpdateStatus {
28 fn as_str(self) -> &'static str {
29 match self {
30 Self::UpToDate => "up_to_date",
31 Self::UpdateAvailable => "update_available",
32 Self::LocalNewer => "local_newer",
33 Self::Unknown => "unknown",
34 }
35 }
36
37 fn label(self) -> &'static str {
38 match self {
39 Self::UpToDate => "up to date",
40 Self::UpdateAvailable => "update available",
41 Self::LocalNewer => "local is newer",
42 Self::Unknown => "unknown",
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
48pub struct UpdateCheckResult {
49 pub crate_name: String,
50 pub current_version: String,
51 pub latest_version: String,
52 pub published_at: Option<String>,
53 pub crate_url: String,
54 pub status: UpdateStatus,
55 pub install_command: String,
56}
57
58#[derive(Debug, Deserialize)]
59struct CratesIoCrateResponse {
60 #[serde(rename = "crate")]
61 crate_meta: CratesIoCrateMeta,
62 #[serde(default)]
63 versions: Vec<CratesIoVersionEntry>,
64}
65
66#[derive(Debug, Deserialize)]
67struct CratesIoCrateMeta {
68 newest_version: String,
69 #[serde(default)]
70 max_stable_version: Option<String>,
71}
72
73#[derive(Debug, Deserialize)]
74struct CratesIoVersionEntry {
75 num: String,
76 #[serde(default)]
77 created_at: Option<String>,
78 #[serde(default)]
79 yanked: bool,
80}
81
82#[derive(Debug, Clone)]
83pub struct UpdateCheckOptions {
84 pub crate_name: String,
85 pub json: bool,
86 pub install: bool,
87 pub fail_if_outdated: bool,
88}
89
90impl Default for UpdateCheckOptions {
91 fn default() -> Self {
92 Self {
93 crate_name: DEFAULT_CRATE_NAME.to_string(),
94 json: false,
95 install: false,
96 fail_if_outdated: false,
97 }
98 }
99}
100
101pub async fn run_update_check(options: UpdateCheckOptions) -> Result<(), String> {
102 let crate_name = normalize_crate_name(&options.crate_name)?;
103 let current_version = env!("CARGO_PKG_VERSION").to_string();
104
105 let loader = if options.json {
106 None
107 } else {
108 Some(Loader::start(&format!(
109 "Checking crates.io for {crate_name}"
110 )))
111 };
112
113 let result = match check_for_update(&crate_name, ¤t_version).await {
114 Ok(result) => {
115 if let Some(loader) = &loader {
116 loader.success_with(&format!(
117 "latest {} ({})",
118 result.latest_version,
119 result.status.label()
120 ));
121 }
122 result
123 }
124 Err(error) => {
125 if let Some(loader) = &loader {
126 loader.fail("lookup failed");
127 }
128 return Err(error);
129 }
130 };
131
132 if options.json {
133 println!(
134 "{}",
135 serde_json::to_string_pretty(&json!({
136 "crate": result.crate_name,
137 "current_version": result.current_version,
138 "latest_version": result.latest_version,
139 "published_at": result.published_at,
140 "status": result.status.as_str(),
141 "update_available": result.status == UpdateStatus::UpdateAvailable,
142 "crate_url": result.crate_url,
143 "install_command": result.install_command,
144 }))
145 .map_err(|error| format!("Failed to serialize update check result: {error}"))?
146 );
147 } else {
148 print_update_report(&result);
149 }
150
151 if options.install {
152 if result.status == UpdateStatus::UpdateAvailable {
153 run_cargo_install(&result.crate_name, &result.latest_version)?;
154 } else if !options.json {
155 tip("Nothing to install; local CLI is already current (or newer than crates.io).");
156 }
157 }
158
159 if options.fail_if_outdated && result.status == UpdateStatus::UpdateAvailable {
160 return Err(format!(
161 "Update available: {} -> {} (crates.io/{})",
162 result.current_version, result.latest_version, result.crate_name
163 ));
164 }
165
166 Ok(())
167}
168
169pub async fn check_for_update(
170 crate_name: &str,
171 current_version: &str,
172) -> Result<UpdateCheckResult, String> {
173 let latest = fetch_crates_io_latest(crate_name).await?;
174 let status = compare_versions(current_version, &latest.version);
175 Ok(UpdateCheckResult {
176 crate_name: crate_name.to_string(),
177 current_version: current_version.to_string(),
178 latest_version: latest.version.clone(),
179 published_at: latest.published_at,
180 crate_url: format!("https://crates.io/crates/{crate_name}"),
181 status,
182 install_command: format!("cargo install {crate_name} --locked"),
183 })
184}
185
186fn print_update_report(result: &UpdateCheckResult) {
187 ui::configure_color_output();
188 println!();
189 println!(
190 "{} {}",
191 "XBP".bright_magenta().bold(),
192 "update check".bright_white().bold()
193 );
194 ui::divider(56);
195
196 status_line("Crate", &result.crate_name, true);
197 status_line("Installed", &format!("v{}", result.current_version), true);
198
199 let latest_ok = matches!(
200 result.status,
201 UpdateStatus::UpToDate | UpdateStatus::LocalNewer
202 );
203 status_line(
204 "crates.io latest",
205 &format!("v{}", result.latest_version),
206 latest_ok,
207 );
208
209 if let Some(published_at) = result.published_at.as_deref() {
210 status_line("Published", published_at, true);
211 }
212
213 let (status_text, status_ok) = match result.status {
214 UpdateStatus::UpToDate => ("up to date".to_string(), true),
215 UpdateStatus::UpdateAvailable => (
216 format!(
217 "update available (v{} → v{})",
218 result.current_version, result.latest_version
219 ),
220 false,
221 ),
222 UpdateStatus::LocalNewer => (
223 format!(
224 "local is newer than crates.io (v{} > v{})",
225 result.current_version, result.latest_version
226 ),
227 true,
228 ),
229 UpdateStatus::Unknown => (
230 "could not compare versions (non-semver?)".to_string(),
231 false,
232 ),
233 };
234 status_line("Status", &status_text, status_ok);
235 ui::divider(56);
236
237 match result.status {
238 UpdateStatus::UpdateAvailable => {
239 tip(&format!("Upgrade with `{}`", result.install_command));
240 tip(&format!("Or open {}", result.crate_url));
241 if result.crate_name == DEFAULT_CRATE_NAME {
242 tip("Quick install: `xbp update --install`");
243 }
244 }
245 UpdateStatus::UpToDate => {
246 tip(&format!("You are on the latest release from {CRATE_PAGE_URL}."));
247 }
248 UpdateStatus::LocalNewer => {
249 tip("This binary is ahead of the published crates.io release (dev build or unpublished bump).");
250 }
251 UpdateStatus::Unknown => {
252 tip(&format!("Inspect releases at {}", result.crate_url));
253 }
254 }
255}
256
257async fn fetch_crates_io_latest(crate_name: &str) -> Result<LatestRelease, String> {
258 let client = reqwest::Client::builder()
259 .user_agent(USER_AGENT)
260 .build()
261 .map_err(|error| format!("Failed to build HTTP client: {error}"))?;
262
263 let url = format!("{CRATES_IO_API_URL}/{crate_name}");
264 let response = client
265 .get(&url)
266 .send()
267 .await
268 .map_err(|error| format!("Failed to query crates.io for `{crate_name}`: {error}"))?;
269
270 if !response.status().is_success() {
271 return Err(format!(
272 "crates.io lookup for `{crate_name}` returned HTTP {}.",
273 response.status().as_u16()
274 ));
275 }
276
277 let payload: CratesIoCrateResponse = response
278 .json()
279 .await
280 .map_err(|error| format!("Failed to parse crates.io response for `{crate_name}`: {error}"))?;
281
282 let latest_version = payload
284 .crate_meta
285 .max_stable_version
286 .filter(|value| !value.trim().is_empty())
287 .unwrap_or(payload.crate_meta.newest_version);
288
289 if latest_version.trim().is_empty() {
290 return Err(format!(
291 "crates.io response for `{crate_name}` did not include a latest version."
292 ));
293 }
294
295 let published_at = payload
296 .versions
297 .iter()
298 .find(|entry| entry.num == latest_version && !entry.yanked)
299 .and_then(|entry| entry.created_at.clone())
300 .or_else(|| {
301 payload
302 .versions
303 .iter()
304 .find(|entry| entry.num == latest_version)
305 .and_then(|entry| entry.created_at.clone())
306 });
307
308 Ok(LatestRelease {
309 version: latest_version,
310 published_at,
311 })
312}
313
314struct LatestRelease {
315 version: String,
316 published_at: Option<String>,
317}
318
319fn compare_versions(current: &str, latest: &str) -> UpdateStatus {
320 let Ok(current) = Version::parse(current.trim().trim_start_matches('v')) else {
321 return UpdateStatus::Unknown;
322 };
323 let Ok(latest) = Version::parse(latest.trim().trim_start_matches('v')) else {
324 return UpdateStatus::Unknown;
325 };
326
327 match current.cmp(&latest) {
328 std::cmp::Ordering::Less => UpdateStatus::UpdateAvailable,
329 std::cmp::Ordering::Equal => UpdateStatus::UpToDate,
330 std::cmp::Ordering::Greater => UpdateStatus::LocalNewer,
331 }
332}
333
334fn normalize_crate_name(name: &str) -> Result<String, String> {
335 let trimmed = name.trim();
336 if trimmed.is_empty() {
337 return Err("Crate name must not be empty.".to_string());
338 }
339 if !trimmed
340 .chars()
341 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
342 {
343 return Err(format!(
344 "Invalid crate name `{trimmed}`. Use letters, digits, `-`, or `_`."
345 ));
346 }
347 Ok(trimmed.to_string())
348}
349
350fn run_cargo_install(crate_name: &str, version: &str) -> Result<(), String> {
351 if which_cargo().is_none() {
352 return Err(
353 "`cargo` was not found on PATH. Install Rust from https://rustup.rs then re-run `xbp update --install`."
354 .to_string(),
355 );
356 }
357
358 println!();
359 println!(
360 "{} {}",
361 "Installing".bright_cyan().bold(),
362 format!("{crate_name}@{version}").bright_white()
363 );
364
365 let status = Command::new("cargo")
366 .args([
367 "install",
368 crate_name,
369 "--locked",
370 "--version",
371 version,
372 "--force",
373 ])
374 .status()
375 .map_err(|error| format!("Failed to spawn `cargo install`: {error}"))?;
376
377 if !status.success() {
378 return Err(format!(
379 "`cargo install {crate_name} --locked --version {version}` failed with status {status}."
380 ));
381 }
382
383 println!(
384 "{} {}",
385 "OK".bright_green().bold(),
386 format!("Installed {crate_name}@{version}").bright_white()
387 );
388 Ok(())
389}
390
391fn which_cargo() -> Option<std::path::PathBuf> {
392 crate::utils::command_exists("cargo").then(|| std::path::PathBuf::from("cargo"))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn compares_semver_update_states() {
401 assert_eq!(
402 compare_versions("10.37.0", "10.38.0"),
403 UpdateStatus::UpdateAvailable
404 );
405 assert_eq!(
406 compare_versions("10.38.0", "10.38.0"),
407 UpdateStatus::UpToDate
408 );
409 assert_eq!(
410 compare_versions("10.38.1", "10.38.0"),
411 UpdateStatus::LocalNewer
412 );
413 assert_eq!(compare_versions("not-a-version", "1.0.0"), UpdateStatus::Unknown);
414 }
415
416 #[test]
417 fn normalizes_crate_names() {
418 assert_eq!(normalize_crate_name("xbp").unwrap(), "xbp");
419 assert!(normalize_crate_name("").is_err());
420 assert!(normalize_crate_name("evil/name").is_err());
421 }
422}