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 DEFAULT_INSTALL_FEATURES: &[&str] = &["all"];
15const USER_AGENT: &str = concat!(
16 "xbp/",
17 env!("CARGO_PKG_VERSION"),
18 " (+https://github.com/xylex-group/xbp)"
19);
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum UpdateStatus {
23 UpToDate,
24 UpdateAvailable,
25 LocalNewer,
26 Unknown,
27}
28
29impl UpdateStatus {
30 fn as_str(self) -> &'static str {
31 match self {
32 Self::UpToDate => "up_to_date",
33 Self::UpdateAvailable => "update_available",
34 Self::LocalNewer => "local_newer",
35 Self::Unknown => "unknown",
36 }
37 }
38
39 fn label(self) -> &'static str {
40 match self {
41 Self::UpToDate => "up to date",
42 Self::UpdateAvailable => "update available",
43 Self::LocalNewer => "local is newer",
44 Self::Unknown => "unknown",
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
50pub struct UpdateCheckResult {
51 pub crate_name: String,
52 pub current_version: String,
53 pub latest_version: String,
54 pub published_at: Option<String>,
55 pub crate_url: String,
56 pub status: UpdateStatus,
57 pub install_command: String,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct InstallFeaturePlan {
63 pub features: Vec<String>,
64 pub no_default_features: bool,
65 pub all_features: bool,
66}
67
68impl InstallFeaturePlan {
69 pub fn resolve(
71 features: &[String],
72 no_default_features: bool,
73 all_features: bool,
74 ) -> Self {
75 let mut normalized = Vec::new();
76 for feature in features {
77 for part in feature.split([',', ' ']) {
78 let part = part.trim();
79 if !part.is_empty() && !normalized.iter().any(|existing| existing == part) {
80 normalized.push(part.to_string());
81 }
82 }
83 }
84
85 if all_features {
86 return Self {
87 features: Vec::new(),
88 no_default_features,
89 all_features: true,
90 };
91 }
92
93 if normalized.is_empty() && !no_default_features {
94 normalized = DEFAULT_INSTALL_FEATURES
95 .iter()
96 .map(|value| (*value).to_string())
97 .collect();
98 }
99
100 Self {
101 features: normalized,
102 no_default_features,
103 all_features: false,
104 }
105 }
106
107 pub fn append_cargo_args(&self, args: &mut Vec<String>) {
108 if self.no_default_features {
109 args.push("--no-default-features".to_string());
110 }
111 if self.all_features {
112 args.push("--all-features".to_string());
113 return;
114 }
115 if !self.features.is_empty() {
116 args.push("--features".to_string());
117 args.push(self.features.join(","));
118 }
119 }
120
121 pub fn command_suffix(&self) -> String {
122 let mut parts = Vec::new();
123 if self.no_default_features {
124 parts.push("--no-default-features".to_string());
125 }
126 if self.all_features {
127 parts.push("--all-features".to_string());
128 } else if !self.features.is_empty() {
129 parts.push(format!("--features {}", self.features.join(",")));
130 }
131 parts.join(" ")
132 }
133}
134
135#[derive(Debug, Deserialize)]
136struct CratesIoCrateResponse {
137 #[serde(rename = "crate")]
138 crate_meta: CratesIoCrateMeta,
139 #[serde(default)]
140 versions: Vec<CratesIoVersionEntry>,
141}
142
143#[derive(Debug, Deserialize)]
144struct CratesIoCrateMeta {
145 newest_version: String,
146 #[serde(default)]
147 max_stable_version: Option<String>,
148}
149
150#[derive(Debug, Deserialize)]
151struct CratesIoVersionEntry {
152 num: String,
153 #[serde(default)]
154 created_at: Option<String>,
155 #[serde(default)]
156 yanked: bool,
157}
158
159#[derive(Debug, Clone)]
160pub struct UpdateCheckOptions {
161 pub crate_name: String,
162 pub json: bool,
163 pub install: bool,
164 pub features: Vec<String>,
165 pub no_default_features: bool,
166 pub all_features: bool,
167 pub fail_if_outdated: bool,
168}
169
170impl Default for UpdateCheckOptions {
171 fn default() -> Self {
172 Self {
173 crate_name: DEFAULT_CRATE_NAME.to_string(),
174 json: false,
175 install: false,
176 features: Vec::new(),
177 no_default_features: false,
178 all_features: false,
179 fail_if_outdated: false,
180 }
181 }
182}
183
184pub async fn run_update_check(options: UpdateCheckOptions) -> Result<(), String> {
185 let crate_name = normalize_crate_name(&options.crate_name)?;
186 let current_version = env!("CARGO_PKG_VERSION").to_string();
187 let feature_plan = InstallFeaturePlan::resolve(
188 &options.features,
189 options.no_default_features,
190 options.all_features,
191 );
192
193 let loader = if options.json {
194 None
195 } else {
196 Some(Loader::start(&format!(
197 "Checking crates.io for {crate_name}"
198 )))
199 };
200
201 let result = match check_for_update(&crate_name, ¤t_version, &feature_plan).await {
202 Ok(result) => {
203 if let Some(loader) = &loader {
204 loader.success_with(&format!(
205 "latest {} ({})",
206 result.latest_version,
207 result.status.label()
208 ));
209 }
210 result
211 }
212 Err(error) => {
213 if let Some(loader) = &loader {
214 loader.fail("lookup failed");
215 }
216 return Err(error);
217 }
218 };
219
220 if options.json {
221 println!(
222 "{}",
223 serde_json::to_string_pretty(&json!({
224 "crate": result.crate_name,
225 "current_version": result.current_version,
226 "latest_version": result.latest_version,
227 "published_at": result.published_at,
228 "status": result.status.as_str(),
229 "update_available": result.status == UpdateStatus::UpdateAvailable,
230 "crate_url": result.crate_url,
231 "install_command": result.install_command,
232 "features": feature_plan.features,
233 "no_default_features": feature_plan.no_default_features,
234 "all_features": feature_plan.all_features,
235 }))
236 .map_err(|error| format!("Failed to serialize update check result: {error}"))?
237 );
238 } else {
239 print_update_report(&result, options.install.then_some(&feature_plan));
240 }
241
242 if options.install {
243 if result.status == UpdateStatus::UpdateAvailable {
244 run_cargo_install(&result.crate_name, &result.latest_version, &feature_plan)?;
245 } else if !options.json {
246 tip("Nothing to install; local CLI is already current (or newer than crates.io).");
247 }
248 }
249
250 if options.fail_if_outdated && result.status == UpdateStatus::UpdateAvailable {
251 return Err(format!(
252 "Update available: {} -> {} (crates.io/{})",
253 result.current_version, result.latest_version, result.crate_name
254 ));
255 }
256
257 Ok(())
258}
259
260pub async fn check_for_update(
261 crate_name: &str,
262 current_version: &str,
263 feature_plan: &InstallFeaturePlan,
264) -> Result<UpdateCheckResult, String> {
265 let latest = fetch_crates_io_latest(crate_name).await?;
266 let status = compare_versions(current_version, &latest.version);
267 let mut install_command = format!("cargo install {crate_name} --locked");
268 let suffix = feature_plan.command_suffix();
269 if !suffix.is_empty() {
270 install_command.push(' ');
271 install_command.push_str(&suffix);
272 }
273 Ok(UpdateCheckResult {
274 crate_name: crate_name.to_string(),
275 current_version: current_version.to_string(),
276 latest_version: latest.version.clone(),
277 published_at: latest.published_at,
278 crate_url: format!("https://crates.io/crates/{crate_name}"),
279 status,
280 install_command,
281 })
282}
283
284fn print_update_report(result: &UpdateCheckResult, feature_plan: Option<&InstallFeaturePlan>) {
285 ui::configure_color_output();
286 println!();
287 println!(
288 "{} {}",
289 "XBP".bright_magenta().bold(),
290 "update check".bright_white().bold()
291 );
292 ui::divider(56);
293
294 status_line("Crate", &result.crate_name, true);
295 status_line("Installed", &format!("v{}", result.current_version), true);
296
297 let latest_ok = matches!(
298 result.status,
299 UpdateStatus::UpToDate | UpdateStatus::LocalNewer
300 );
301 status_line(
302 "crates.io latest",
303 &format!("v{}", result.latest_version),
304 latest_ok,
305 );
306
307 if let Some(published_at) = result.published_at.as_deref() {
308 status_line("Published", published_at, true);
309 }
310
311 if let Some(plan) = feature_plan {
312 let features_label = if plan.all_features {
313 "all-features".to_string()
314 } else if plan.features.is_empty() {
315 if plan.no_default_features {
316 "(none)".to_string()
317 } else {
318 "(package defaults)".to_string()
319 }
320 } else {
321 plan.features.join(", ")
322 };
323 status_line("Install features", &features_label, true);
324 if plan.no_default_features {
325 status_line("Default features", "disabled", true);
326 }
327 }
328
329 let (status_text, status_ok) = match result.status {
330 UpdateStatus::UpToDate => ("up to date".to_string(), true),
331 UpdateStatus::UpdateAvailable => (
332 format!(
333 "update available (v{} → v{})",
334 result.current_version, result.latest_version
335 ),
336 false,
337 ),
338 UpdateStatus::LocalNewer => (
339 format!(
340 "local is newer than crates.io (v{} > v{})",
341 result.current_version, result.latest_version
342 ),
343 true,
344 ),
345 UpdateStatus::Unknown => (
346 "could not compare versions (non-semver?)".to_string(),
347 false,
348 ),
349 };
350 status_line("Status", &status_text, status_ok);
351 ui::divider(56);
352
353 match result.status {
354 UpdateStatus::UpdateAvailable => {
355 tip(&format!("Upgrade with `{}`", result.install_command));
356 tip(&format!("Or open {}", result.crate_url));
357 if result.crate_name == DEFAULT_CRATE_NAME {
358 tip("Quick install: `xbp update --install` (defaults to --features all)");
359 }
360 }
361 UpdateStatus::UpToDate => {
362 tip(&format!("You are on the latest release from {CRATE_PAGE_URL}."));
363 }
364 UpdateStatus::LocalNewer => {
365 tip("This binary is ahead of the published crates.io release (dev build or unpublished bump).");
366 }
367 UpdateStatus::Unknown => {
368 tip(&format!("Inspect releases at {}", result.crate_url));
369 }
370 }
371}
372
373async fn fetch_crates_io_latest(crate_name: &str) -> Result<LatestRelease, String> {
374 let client = reqwest::Client::builder()
375 .user_agent(USER_AGENT)
376 .build()
377 .map_err(|error| format!("Failed to build HTTP client: {error}"))?;
378
379 let url = format!("{CRATES_IO_API_URL}/{crate_name}");
380 let response = client
381 .get(&url)
382 .send()
383 .await
384 .map_err(|error| format!("Failed to query crates.io for `{crate_name}`: {error}"))?;
385
386 if !response.status().is_success() {
387 return Err(format!(
388 "crates.io lookup for `{crate_name}` returned HTTP {}.",
389 response.status().as_u16()
390 ));
391 }
392
393 let payload: CratesIoCrateResponse = response
394 .json()
395 .await
396 .map_err(|error| format!("Failed to parse crates.io response for `{crate_name}`: {error}"))?;
397
398 let latest_version = payload
400 .crate_meta
401 .max_stable_version
402 .filter(|value| !value.trim().is_empty())
403 .unwrap_or(payload.crate_meta.newest_version);
404
405 if latest_version.trim().is_empty() {
406 return Err(format!(
407 "crates.io response for `{crate_name}` did not include a latest version."
408 ));
409 }
410
411 let published_at = payload
412 .versions
413 .iter()
414 .find(|entry| entry.num == latest_version && !entry.yanked)
415 .and_then(|entry| entry.created_at.clone())
416 .or_else(|| {
417 payload
418 .versions
419 .iter()
420 .find(|entry| entry.num == latest_version)
421 .and_then(|entry| entry.created_at.clone())
422 });
423
424 Ok(LatestRelease {
425 version: latest_version,
426 published_at,
427 })
428}
429
430struct LatestRelease {
431 version: String,
432 published_at: Option<String>,
433}
434
435fn compare_versions(current: &str, latest: &str) -> UpdateStatus {
436 let Ok(current) = Version::parse(current.trim().trim_start_matches('v')) else {
437 return UpdateStatus::Unknown;
438 };
439 let Ok(latest) = Version::parse(latest.trim().trim_start_matches('v')) else {
440 return UpdateStatus::Unknown;
441 };
442
443 match current.cmp(&latest) {
444 std::cmp::Ordering::Less => UpdateStatus::UpdateAvailable,
445 std::cmp::Ordering::Equal => UpdateStatus::UpToDate,
446 std::cmp::Ordering::Greater => UpdateStatus::LocalNewer,
447 }
448}
449
450fn normalize_crate_name(name: &str) -> Result<String, String> {
451 let trimmed = name.trim();
452 if trimmed.is_empty() {
453 return Err("Crate name must not be empty.".to_string());
454 }
455 if !trimmed
456 .chars()
457 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
458 {
459 return Err(format!(
460 "Invalid crate name `{trimmed}`. Use letters, digits, `-`, or `_`."
461 ));
462 }
463 Ok(trimmed.to_string())
464}
465
466fn run_cargo_install(
467 crate_name: &str,
468 version: &str,
469 feature_plan: &InstallFeaturePlan,
470) -> Result<(), String> {
471 if which_cargo().is_none() {
472 return Err(
473 "`cargo` was not found on PATH. Install Rust from https://rustup.rs then re-run `xbp update --install`."
474 .to_string(),
475 );
476 }
477
478 if crate_name == DEFAULT_CRATE_NAME {
481 crate::commands::worktree_watch::stop_active_worktree_watch_before_install();
482 crate::commands::worktree_watch_tray::stop_worktree_watch_tray_before_install();
483 }
484
485 let mut args = vec![
486 "install".to_string(),
487 crate_name.to_string(),
488 "--locked".to_string(),
489 "--version".to_string(),
490 version.to_string(),
491 "--force".to_string(),
492 ];
493 feature_plan.append_cargo_args(&mut args);
494 let display = format!("cargo {}", args.join(" "));
495
496 println!();
497 println!(
498 "{} {}",
499 "Installing".bright_cyan().bold(),
500 format!("{crate_name}@{version}").bright_white()
501 );
502 if !feature_plan.command_suffix().is_empty() {
503 status_line("Features", &feature_plan.command_suffix(), true);
504 }
505 tip(&format!("Running `{display}`"));
506
507 let status = Command::new("cargo")
508 .args(&args)
509 .status()
510 .map_err(|error| format!("Failed to spawn `cargo install`: {error}"))?;
511
512 if !status.success() {
513 return Err(format!("`{display}` failed with status {status}."));
514 }
515
516 println!(
517 "{} {}",
518 "OK".bright_green().bold(),
519 format!("Installed {crate_name}@{version}").bright_white()
520 );
521 Ok(())
522}
523
524fn which_cargo() -> Option<std::path::PathBuf> {
525 crate::utils::command_exists("cargo").then(|| std::path::PathBuf::from("cargo"))
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn compares_semver_update_states() {
534 assert_eq!(
535 compare_versions("10.37.0", "10.38.0"),
536 UpdateStatus::UpdateAvailable
537 );
538 assert_eq!(
539 compare_versions("10.38.0", "10.38.0"),
540 UpdateStatus::UpToDate
541 );
542 assert_eq!(
543 compare_versions("10.38.1", "10.38.0"),
544 UpdateStatus::LocalNewer
545 );
546 assert_eq!(compare_versions("not-a-version", "1.0.0"), UpdateStatus::Unknown);
547 }
548
549 #[test]
550 fn normalizes_crate_names() {
551 assert_eq!(normalize_crate_name("xbp").unwrap(), "xbp");
552 assert!(normalize_crate_name("").is_err());
553 assert!(normalize_crate_name("evil/name").is_err());
554 }
555
556 #[test]
557 fn install_features_default_to_all() {
558 let plan = InstallFeaturePlan::resolve(&[], false, false);
559 assert_eq!(plan.features, vec!["all".to_string()]);
560 assert!(!plan.all_features);
561 assert!(!plan.no_default_features);
562 assert_eq!(plan.command_suffix(), "--features all");
563 }
564
565 #[test]
566 fn install_features_respect_explicit_list() {
567 let plan = InstallFeaturePlan::resolve(
568 &["secrets".to_string(), "docker,linear".to_string()],
569 false,
570 false,
571 );
572 assert_eq!(
573 plan.features,
574 vec![
575 "secrets".to_string(),
576 "docker".to_string(),
577 "linear".to_string()
578 ]
579 );
580 let mut args = Vec::new();
581 plan.append_cargo_args(&mut args);
582 assert_eq!(
583 args,
584 vec!["--features".to_string(), "secrets,docker,linear".to_string()]
585 );
586 }
587
588 #[test]
589 fn install_features_all_features_flag_wins() {
590 let plan = InstallFeaturePlan::resolve(&["secrets".to_string()], true, true);
591 assert!(plan.all_features);
592 assert!(plan.no_default_features);
593 assert!(plan.features.is_empty());
594 assert_eq!(
595 plan.command_suffix(),
596 "--no-default-features --all-features"
597 );
598 }
599
600 #[test]
601 fn install_features_no_default_without_list_skips_all() {
602 let plan = InstallFeaturePlan::resolve(&[], true, false);
603 assert!(plan.features.is_empty());
604 assert!(plan.no_default_features);
605 assert_eq!(plan.command_suffix(), "--no-default-features");
606 }
607}