Skip to main content

claudex_cli/commands/
update.rs

1//! Self-update command.
2//!
3//! Downloads the latest claudex release from GitHub, verifies its SHA-256
4//! checksum against `SHA256SUMS`, and atomically swaps the running binary —
5//! but only when the binary was installed somewhere we can write. If it came
6//! from Nix, cargo, or Homebrew, we print the correct upgrade recipe for that
7//! channel instead of clobbering a store path.
8//!
9//! The tag of the latest release is resolved by following the redirect on
10//! `/releases/latest` with `curl -sLI`, so we never hit `api.github.com` and
11//! therefore never trip its unauthenticated rate limit — same trick
12//! `install.sh` uses.
13
14use std::io::Read as _;
15use std::path::Path;
16use std::process::{Command, Stdio};
17
18use anyhow::{Context, Result, anyhow, bail};
19use sha2::{Digest, Sha256};
20
21const GITHUB_REPO: &str = "utensils/claudex";
22
23// ── Version comparison ──────────────────────────────────────────────────────
24
25/// Parse `0.6.1` or `v0.6.1` into `(major, minor, patch)`.
26fn parse_version(v: &str) -> Option<(u32, u32, u32)> {
27    let v = v.strip_prefix('v').unwrap_or(v);
28    let parts: Vec<&str> = v.split('.').collect();
29    if parts.len() != 3 {
30        return None;
31    }
32    Some((
33        parts[0].parse().ok()?,
34        parts[1].parse().ok()?,
35        parts[2].parse().ok()?,
36    ))
37}
38
39/// True iff `remote` parses as strictly newer than `current`.
40fn is_newer(current: &str, remote: &str) -> bool {
41    match (parse_version(current), parse_version(remote)) {
42        (Some(c), Some(r)) => r > c,
43        _ => false,
44    }
45}
46
47// ── Install-source detection ────────────────────────────────────────────────
48
49#[derive(Debug, PartialEq, Eq, Copy, Clone)]
50pub enum InstallKind {
51    /// `/nix/store/...` — immutable, user upgrades via their flake.
52    Nix,
53    /// `~/.cargo/bin/claudex` — installed with `cargo install`.
54    Cargo,
55    /// `/opt/homebrew/...` or `/usr/local/Cellar/...`.
56    Homebrew,
57    /// `/usr/bin/claudex` on Linux — installed by a system package manager
58    /// (Arch's `pacman`, e.g. via the AUR `claudex-bin` / `claudex` /
59    /// `claudex-git` packages). Self-update must not touch this path:
60    /// without sudo it would fail with EACCES; with sudo it would silently
61    /// overwrite a pacman-owned file outside the package database.
62    Pacman,
63    /// Anything else — assumed to be installed by `install.sh` (or copied by
64    /// hand) and therefore safe to replace in place.
65    Managed,
66}
67
68impl InstallKind {
69    fn label(self) -> &'static str {
70        match self {
71            Self::Nix => "Nix",
72            Self::Cargo => "cargo",
73            Self::Homebrew => "Homebrew",
74            Self::Pacman => "pacman (AUR)",
75            Self::Managed => "install.sh",
76        }
77    }
78}
79
80pub fn detect_install_kind(exe_path: &Path) -> InstallKind {
81    let p = exe_path.to_string_lossy();
82    if p.contains("/nix/store/") {
83        InstallKind::Nix
84    } else if p.contains("/Cellar/") || p.contains("/homebrew/") {
85        InstallKind::Homebrew
86    } else if p.contains("/.cargo/bin/") || p.contains("/cargo/bin/") {
87        InstallKind::Cargo
88    } else if cfg!(target_os = "linux")
89        && (p.starts_with("/usr/bin/") || p.starts_with("/usr/sbin/"))
90    {
91        // Arch's usrmerge symlinks /usr/sbin → /usr/bin, so `which claudex`
92        // on an AUR install can resolve to either path. /usr/local/bin is
93        // intentionally excluded — that's the conventional install.sh
94        // override target and unowned by any package manager.
95        InstallKind::Pacman
96    } else {
97        InstallKind::Managed
98    }
99}
100
101/// Recommended upgrade command for each non-self-updatable install source.
102/// Returns `None` for [`InstallKind::Managed`] — that path proceeds to
103/// download-and-swap.
104fn upgrade_hint(kind: InstallKind, target_tag: &str) -> Option<String> {
105    match kind {
106        InstallKind::Nix => Some(
107            "  nix profile upgrade claudex\n  \
108             or, if claudex is a flake input:\n    \
109             nix flake update claudex"
110                .to_string(),
111        ),
112        InstallKind::Cargo => Some(format!(
113            "  cargo install claudex-cli --version {} --force",
114            target_tag.trim_start_matches('v')
115        )),
116        InstallKind::Homebrew => Some("  brew upgrade claudex".to_string()),
117        InstallKind::Pacman => Some(
118            "  paru -Syu claudex-bin   # or claudex / claudex-git, depending on which AUR package you installed\n  \
119             or with any other AUR helper / vanilla pacman:\n    \
120             yay -Syu claudex-bin\n    \
121             sudo pacman -Syu       # if upstream syncs the package"
122                .to_string(),
123        ),
124        InstallKind::Managed => None,
125    }
126}
127
128// ── Platform asset detection ────────────────────────────────────────────────
129
130/// The release asset name for the current target triple. Kept in sync with
131/// `install.sh` and `release.yml`.
132fn detect_asset_name() -> Result<&'static str> {
133    match (std::env::consts::OS, std::env::consts::ARCH) {
134        ("macos", "aarch64") => Ok("claudex-aarch64-apple-darwin.tar.gz"),
135        ("macos", "x86_64") => Ok("claudex-x86_64-apple-darwin.tar.gz"),
136        ("linux", "x86_64") => Ok("claudex-x86_64-unknown-linux-gnu.tar.gz"),
137        ("linux", "aarch64") => Ok("claudex-aarch64-unknown-linux-gnu.tar.gz"),
138        (os, arch) => bail!("unsupported platform for self-update: {os}/{arch}"),
139    }
140}
141
142// ── SHA-256 verification ────────────────────────────────────────────────────
143
144/// Parse a `SHA256SUMS` file (`{hash}  {filename}` per line, two-space
145/// separator per sha256sum convention) and compare against the SHA-256 of
146/// `data`.
147fn verify_checksum(sums: &str, asset: &str, data: &[u8]) -> Result<()> {
148    let expected = sums
149        .lines()
150        .find_map(|line| {
151            let (hash, name) = line.split_once("  ")?;
152            (name.trim() == asset).then(|| hash.trim().to_string())
153        })
154        .with_context(|| format!("asset {asset} not found in SHA256SUMS"))?;
155
156    let mut hasher = Sha256::new();
157    hasher.update(data);
158    let actual = format!("{:x}", hasher.finalize());
159
160    if actual != expected {
161        bail!(
162            "SHA-256 checksum mismatch for {asset}\n  expected: {expected}\n  actual:   {actual}"
163        );
164    }
165    Ok(())
166}
167
168// ── Tarball extraction ──────────────────────────────────────────────────────
169
170/// Pull the `claudex` binary out of a `.tar.gz` archive regardless of its
171/// position in the archive.
172fn extract_binary_from_tarball(data: &[u8]) -> Result<Vec<u8>> {
173    let decoder = flate2::read::GzDecoder::new(data);
174    let mut archive = tar::Archive::new(decoder);
175
176    for entry in archive.entries()? {
177        let mut entry = entry?;
178        let path = entry.path()?;
179        if path.file_name().map(|n| n == "claudex").unwrap_or(false) {
180            let mut buf = Vec::new();
181            entry.read_to_end(&mut buf)?;
182            return Ok(buf);
183        }
184    }
185    bail!("'claudex' binary not found in release archive")
186}
187
188// ── Binary self-replacement ─────────────────────────────────────────────────
189
190/// Replace the binary at `exe_path` with `new_binary`. The swap goes
191/// current → `.old` → new, and on failure we put the original back before
192/// returning. macOS quarantine flag is cleared on success.
193fn replace_binary(new_binary: &[u8], exe_path: &Path) -> Result<()> {
194    use std::os::unix::fs::PermissionsExt;
195
196    let exe_dir = exe_path
197        .parent()
198        .context("cannot determine binary directory")?;
199    let pid = std::process::id();
200    let tmp_path = exe_dir.join(format!(".claudex-update-{pid}"));
201    let backup_path = exe_path.with_extension("old");
202
203    std::fs::write(&tmp_path, new_binary).context("failed to write new binary to temp file")?;
204    std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o755))
205        .context("failed to set permissions on new binary")?;
206
207    std::fs::rename(exe_path, &backup_path).context("failed to move current binary to backup")?;
208
209    if let Err(e) = std::fs::rename(&tmp_path, exe_path) {
210        let _ = std::fs::rename(&backup_path, exe_path);
211        let _ = std::fs::remove_file(&tmp_path);
212        bail!("failed to install new binary: {e}");
213    }
214
215    let _ = std::fs::remove_file(&backup_path);
216
217    #[cfg(target_os = "macos")]
218    {
219        let _ = Command::new("xattr")
220            .args(["-d", "com.apple.quarantine"])
221            .arg(exe_path)
222            .output();
223    }
224
225    Ok(())
226}
227
228// ── Network (via curl) ──────────────────────────────────────────────────────
229
230fn ensure_curl() -> Result<()> {
231    let ok = Command::new("curl")
232        .arg("--version")
233        .stdout(Stdio::null())
234        .stderr(Stdio::null())
235        .status()
236        .map(|s| s.success())
237        .unwrap_or(false);
238    if !ok {
239        bail!("`curl` is required for `claudex update` but was not found in PATH");
240    }
241    Ok(())
242}
243
244/// Extract the tag segment (`vX.Y.Z`) from the final URL that `/releases/latest`
245/// redirects to: `https://github.com/<owner>/<repo>/releases/tag/vX.Y.Z`.
246fn tag_from_redirect(url: &str) -> Option<String> {
247    let trimmed = url.trim().trim_end_matches('/');
248    let tag = trimmed.rsplit('/').next()?;
249    if tag.starts_with('v') && parse_version(tag).is_some() {
250        Some(tag.to_string())
251    } else {
252        None
253    }
254}
255
256/// HEAD `/releases/latest`, follow redirects, read the final URL. No API call.
257fn fetch_latest_tag() -> Result<String> {
258    let url = format!("https://github.com/{GITHUB_REPO}/releases/latest");
259    let out = Command::new("curl")
260        .args(["-sLI", "-o", "/dev/null", "-w", "%{url_effective}", &url])
261        .output()
262        .context("failed to invoke curl")?;
263    if !out.status.success() {
264        bail!(
265            "curl failed while resolving latest release (exit {:?})",
266            out.status.code()
267        );
268    }
269    let final_url = String::from_utf8_lossy(&out.stdout);
270    tag_from_redirect(&final_url)
271        .ok_or_else(|| anyhow!("unexpected redirect target: {}", final_url.trim()))
272}
273
274fn fetch_url(url: &str) -> Result<Vec<u8>> {
275    let out = Command::new("curl")
276        .args(["-fsSL", url])
277        .output()
278        .context("failed to invoke curl")?;
279    if !out.status.success() {
280        let stderr = String::from_utf8_lossy(&out.stderr);
281        bail!("curl failed to fetch {url}:\n  {}", stderr.trim());
282    }
283    Ok(out.stdout)
284}
285
286// ── Main command ────────────────────────────────────────────────────────────
287
288pub fn run(check: bool, force: bool, version: Option<String>) -> Result<()> {
289    let current = env!("CARGO_PKG_VERSION");
290    eprintln!("Current version: {current}");
291
292    ensure_curl()?;
293
294    let target_tag = match version.as_deref() {
295        Some(v) => {
296            if v.starts_with('v') {
297                v.to_string()
298            } else {
299                format!("v{v}")
300            }
301        }
302        None => {
303            eprintln!("Checking for updates...");
304            fetch_latest_tag()?
305        }
306    };
307    let remote = target_tag.strip_prefix('v').unwrap_or(&target_tag);
308
309    // Short-circuit: already on the requested version, no --force.
310    if !force && remote == current && version.is_none() {
311        eprintln!("✓ Already up to date ({current})");
312        return Ok(());
313    }
314
315    let action = if is_newer(current, remote) {
316        "Updating"
317    } else if remote == current {
318        "Reinstalling"
319    } else {
320        "Downgrading"
321    };
322
323    // --check: report and exit without touching disk.
324    if check {
325        if is_newer(current, remote) {
326            eprintln!("→ New version available: {remote} (current: {current})");
327        } else if remote == current {
328            eprintln!("✓ Up to date ({current})");
329        } else {
330            eprintln!("→ Version {remote} is available (current: {current})");
331        }
332        return Ok(());
333    }
334
335    // From here on we write to disk — validate the install location.
336    let exe_path = std::env::current_exe()?.canonicalize()?;
337    let kind = detect_install_kind(&exe_path);
338    if let Some(hint) = upgrade_hint(kind, &target_tag) {
339        eprintln!(
340            "claudex was installed via {} at {}.",
341            kind.label(),
342            exe_path.display()
343        );
344        eprintln!("In-place self-update isn't supported for this install source.");
345        eprintln!("To upgrade to {target_tag}, run:");
346        eprintln!("{hint}");
347        std::process::exit(1);
348    }
349
350    if let Some(exe_dir) = exe_path.parent() {
351        let probe = exe_dir.join(format!(".claudex-update-test-{}", std::process::id()));
352        match std::fs::write(&probe, b"") {
353            Ok(()) => {
354                let _ = std::fs::remove_file(&probe);
355            }
356            Err(_) => bail!(
357                "no write permission to {}. Re-run with sudo or reinstall with \
358                 CLAUDEX_INSTALL_DIR pointing at a user-writable directory.",
359                exe_dir.display()
360            ),
361        }
362    }
363
364    eprintln!("{action}: {current} → {remote}");
365
366    let asset_name = detect_asset_name()?;
367    let asset_url =
368        format!("https://github.com/{GITHUB_REPO}/releases/download/{target_tag}/{asset_name}");
369    let sums_url =
370        format!("https://github.com/{GITHUB_REPO}/releases/download/{target_tag}/SHA256SUMS");
371
372    let archive = fetch_url(&asset_url)?;
373    let sums =
374        String::from_utf8(fetch_url(&sums_url)?).context("SHA256SUMS contained non-UTF-8 data")?;
375
376    verify_checksum(&sums, asset_name, &archive)?;
377    eprintln!("Checksum verified (SHA-256).");
378
379    let binary = extract_binary_from_tarball(&archive)?;
380    replace_binary(&binary, &exe_path)?;
381
382    eprintln!(
383        "✓ {action} complete: claudex {remote} ({})",
384        exe_path.display()
385    );
386    Ok(())
387}
388
389// ── Tests ───────────────────────────────────────────────────────────────────
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use std::io::Write as _;
395
396    // Version parsing / comparison
397
398    #[test]
399    fn parse_version_valid() {
400        assert_eq!(parse_version("0.6.1"), Some((0, 6, 1)));
401        assert_eq!(parse_version("v1.2.3"), Some((1, 2, 3)));
402        assert_eq!(parse_version("10.20.30"), Some((10, 20, 30)));
403    }
404
405    #[test]
406    fn parse_version_invalid() {
407        assert_eq!(parse_version(""), None);
408        assert_eq!(parse_version("1.2"), None);
409        assert_eq!(parse_version("1.2.3.4"), None);
410        assert_eq!(parse_version("abc"), None);
411        assert_eq!(parse_version("1.2.x"), None);
412    }
413
414    #[test]
415    fn is_newer_basic() {
416        assert!(is_newer("0.2.0", "0.3.0"));
417        assert!(is_newer("0.2.1", "0.2.2"));
418        assert!(!is_newer("0.2.1", "0.2.1"));
419        assert!(!is_newer("1.0.0", "0.9.9"));
420    }
421
422    #[test]
423    fn is_newer_tolerates_v_prefix() {
424        assert!(is_newer("0.2.0", "v0.3.0"));
425        assert!(is_newer("v0.2.0", "0.3.0"));
426        assert!(!is_newer("v0.3.0", "v0.2.0"));
427    }
428
429    // Install-source detection
430
431    #[test]
432    fn install_kind_nix() {
433        let p = Path::new("/nix/store/abc123-claudex/bin/claudex");
434        assert_eq!(detect_install_kind(p), InstallKind::Nix);
435    }
436
437    #[test]
438    fn install_kind_cargo() {
439        let p = Path::new("/Users/alice/.cargo/bin/claudex");
440        assert_eq!(detect_install_kind(p), InstallKind::Cargo);
441    }
442
443    #[test]
444    fn install_kind_homebrew_silicon() {
445        let p = Path::new("/opt/homebrew/bin/claudex");
446        assert_eq!(detect_install_kind(p), InstallKind::Homebrew);
447    }
448
449    #[test]
450    fn install_kind_homebrew_intel() {
451        let p = Path::new("/usr/local/Cellar/claudex/0.2.0/bin/claudex");
452        assert_eq!(detect_install_kind(p), InstallKind::Homebrew);
453    }
454
455    #[test]
456    fn install_kind_managed_local_bin() {
457        assert_eq!(
458            detect_install_kind(Path::new("/Users/alice/.local/bin/claudex")),
459            InstallKind::Managed,
460        );
461    }
462
463    #[test]
464    fn install_kind_managed_usr_local() {
465        // /usr/local/bin is the conventional install.sh override target —
466        // never auto-classified as a package-managed path.
467        assert_eq!(
468            detect_install_kind(Path::new("/usr/local/bin/claudex")),
469            InstallKind::Managed,
470        );
471    }
472
473    #[test]
474    #[cfg(target_os = "linux")]
475    fn install_kind_pacman_usr_bin() {
476        assert_eq!(
477            detect_install_kind(Path::new("/usr/bin/claudex")),
478            InstallKind::Pacman,
479        );
480    }
481
482    #[test]
483    #[cfg(target_os = "linux")]
484    fn install_kind_pacman_usr_sbin() {
485        // Arch's usrmerge symlinks /usr/sbin → /usr/bin.
486        assert_eq!(
487            detect_install_kind(Path::new("/usr/sbin/claudex")),
488            InstallKind::Pacman,
489        );
490    }
491
492    #[test]
493    #[cfg(not(target_os = "linux"))]
494    fn install_kind_usr_bin_is_managed_off_linux() {
495        // On macOS /usr/bin is system territory but pacman doesn't exist
496        // there; classify as Managed so `update` doesn't print Linux hints
497        // to a Darwin user.
498        assert_eq!(
499            detect_install_kind(Path::new("/usr/bin/claudex")),
500            InstallKind::Managed,
501        );
502    }
503
504    #[test]
505    fn upgrade_hint_per_kind() {
506        assert!(
507            upgrade_hint(InstallKind::Nix, "v1.2.3")
508                .unwrap()
509                .contains("nix")
510        );
511        let cargo = upgrade_hint(InstallKind::Cargo, "v1.2.3").unwrap();
512        assert!(cargo.contains("cargo install"));
513        assert!(cargo.contains("--version 1.2.3"));
514        assert!(
515            upgrade_hint(InstallKind::Homebrew, "v1.2.3")
516                .unwrap()
517                .contains("brew")
518        );
519        let pacman = upgrade_hint(InstallKind::Pacman, "v1.2.3").unwrap();
520        assert!(pacman.contains("paru"));
521        assert!(pacman.contains("claudex-bin"));
522        assert_eq!(upgrade_hint(InstallKind::Managed, "v1.2.3"), None);
523    }
524
525    // Platform asset detection — existence, not value (varies per host).
526
527    #[test]
528    fn asset_name_is_valid_for_current_platform() {
529        let name = detect_asset_name();
530        assert!(name.is_ok(), "detect_asset_name failed: {name:?}");
531        let name = name.unwrap();
532        assert!(name.starts_with("claudex-"));
533        assert!(name.ends_with(".tar.gz"));
534    }
535
536    // Checksum verification
537
538    #[test]
539    fn verify_checksum_matches() {
540        let data = b"hello world";
541        let mut h = Sha256::new();
542        h.update(data);
543        let hash = format!("{:x}", h.finalize());
544        let sums = format!("{hash}  test.tar.gz\n");
545        assert!(verify_checksum(&sums, "test.tar.gz", data).is_ok());
546    }
547
548    #[test]
549    fn verify_checksum_mismatch() {
550        let sums =
551            "0000000000000000000000000000000000000000000000000000000000000000  test.tar.gz\n";
552        let err = verify_checksum(sums, "test.tar.gz", b"data").unwrap_err();
553        assert!(err.to_string().contains("checksum mismatch"));
554    }
555
556    #[test]
557    fn verify_checksum_missing_asset() {
558        let sums = "abcdef1234567890  other.tar.gz\n";
559        let err = verify_checksum(sums, "missing.tar.gz", b"data").unwrap_err();
560        assert!(err.to_string().contains("not found"));
561    }
562
563    #[test]
564    fn verify_checksum_multi_line() {
565        let a = b"file-a";
566        let b = b"file-b";
567        let mut ha = Sha256::new();
568        ha.update(a);
569        let mut hb = Sha256::new();
570        hb.update(b);
571        let sums = format!(
572            "{ha:x}  a.tar.gz\n{hb:x}  b.tar.gz\n",
573            ha = ha.finalize(),
574            hb = hb.finalize(),
575        );
576        assert!(verify_checksum(&sums, "a.tar.gz", a).is_ok());
577        assert!(verify_checksum(&sums, "b.tar.gz", b).is_ok());
578    }
579
580    // Tarball extraction
581
582    fn make_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
583        let mut builder = tar::Builder::new(Vec::new());
584        for (name, data) in entries {
585            let mut header = tar::Header::new_gnu();
586            header.set_size(data.len() as u64);
587            header.set_mode(0o755);
588            header.set_cksum();
589            builder.append_data(&mut header, name, *data).unwrap();
590        }
591        let tar_bytes = builder.into_inner().unwrap();
592        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
593        gz.write_all(&tar_bytes).unwrap();
594        gz.finish().unwrap()
595    }
596
597    #[test]
598    fn extract_finds_claudex_binary() {
599        let expected = b"fake-claudex-bytes";
600        let archive = make_tarball(&[("claudex", expected)]);
601        assert_eq!(extract_binary_from_tarball(&archive).unwrap(), expected);
602    }
603
604    #[test]
605    fn extract_skips_sibling_files() {
606        let expected = b"the-real-claudex";
607        let archive = make_tarball(&[
608            ("README.md", b"docs"),
609            ("claudex", expected),
610            ("LICENSE", b"license"),
611        ]);
612        assert_eq!(extract_binary_from_tarball(&archive).unwrap(), expected);
613    }
614
615    #[test]
616    fn extract_errors_when_binary_missing() {
617        let archive = make_tarball(&[("not-claudex", b"oops")]);
618        let err = extract_binary_from_tarball(&archive).unwrap_err();
619        assert!(err.to_string().contains("not found in release archive"));
620    }
621
622    // Binary replacement
623
624    #[test]
625    fn replace_binary_swaps_and_preserves_perms() {
626        use std::os::unix::fs::PermissionsExt;
627        let dir = tempfile::tempdir().unwrap();
628        let exe = dir.path().join("claudex");
629        std::fs::write(&exe, b"old").unwrap();
630        std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
631
632        replace_binary(b"new-v2", &exe).unwrap();
633
634        assert_eq!(std::fs::read(&exe).unwrap(), b"new-v2");
635        let mode = std::fs::metadata(&exe).unwrap().permissions().mode() & 0o777;
636        assert_eq!(mode, 0o755);
637        assert!(!exe.with_extension("old").exists());
638    }
639
640    #[test]
641    fn replace_binary_leaves_no_temp_files() {
642        let dir = tempfile::tempdir().unwrap();
643        let exe = dir.path().join("claudex");
644        std::fs::write(&exe, b"orig").unwrap();
645
646        replace_binary(b"next", &exe).unwrap();
647
648        let stragglers: Vec<_> = std::fs::read_dir(dir.path())
649            .unwrap()
650            .filter_map(|e| e.ok())
651            .filter(|e| {
652                let name = e.file_name();
653                let s = name.to_string_lossy();
654                s.starts_with(".claudex-update-") || s.ends_with(".old")
655            })
656            .collect();
657        assert!(stragglers.is_empty(), "leftovers: {stragglers:?}");
658    }
659
660    // Redirect parsing
661
662    #[test]
663    fn tag_from_redirect_strips_to_tag() {
664        assert_eq!(
665            tag_from_redirect("https://github.com/utensils/claudex/releases/tag/v0.2.0"),
666            Some("v0.2.0".to_string()),
667        );
668    }
669
670    #[test]
671    fn tag_from_redirect_trims_whitespace_and_trailing_slash() {
672        assert_eq!(
673            tag_from_redirect("  https://github.com/utensils/claudex/releases/tag/v1.2.3/\n"),
674            Some("v1.2.3".to_string()),
675        );
676    }
677
678    #[test]
679    fn tag_from_redirect_rejects_non_version_suffix() {
680        assert_eq!(tag_from_redirect("https://example.com/not-a-release"), None,);
681        // Looks like a tag but doesn't parse as three numeric components.
682        assert_eq!(
683            tag_from_redirect("https://github.com/u/r/releases/tag/vdev"),
684            None,
685        );
686    }
687}