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";
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, &current_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    // Prefer the declared newest version; fall back to max stable if present.
283    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}