Skip to main content

xbp_cli/commands/
update.rs

1//! Check for newer XBP releases on crates.io (and optionally install them).
2
3use 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";
13/// Default Cargo features for `xbp update --install` when the user does not pass `--features`.
14const 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/// Feature selection passed through to `cargo install`.
61#[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    /// Resolve install features: bare `--install` defaults to `--features all`.
70    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, &current_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    // Prefer the declared newest version; fall back to max stable if present.
399    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    // Release worktree-watch (parent/repos + tray) before cargo tries to replace
479    // the installed binary — otherwise Windows holds a file claim on xbp.exe.
480    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}