Skip to main content

release_kit/
probes.rs

1//! The environment probe catalog.
2//!
3//! One catalog, read by every caller that needs to know whether this host
4//! is ready: `rk doctor` runs it whole, and a mutating command guards the
5//! subset it depends on at entry, so the per-command guards and the
6//! doctor cannot drift apart. Each probe answers with a status, a
7//! message, and — on failure — the remediation printed verbatim wherever
8//! the probe is consulted.
9
10use std::process::Command;
11
12use serde::Serialize;
13
14use crate::detect::Forge;
15use crate::skills::record::{RECORD_PATH, Record};
16use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest};
17
18/// How a failure weighs at the doctor level.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22    /// No mutating command can work without this.
23    Hard,
24    /// Needed only by some commands or some forges.
25    Soft,
26}
27
28/// What a probe found.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32    /// The probe passed.
33    Ok,
34    /// The probe failed; the remediation says what fixes it.
35    Failed,
36}
37
38/// One probe's answer.
39#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41    /// The probe's stable name.
42    pub id: &'static str,
43    /// How the failure weighs.
44    pub class: ProbeClass,
45    /// What was found.
46    pub status: ProbeStatus,
47    /// What was found, one line.
48    pub message: String,
49    /// The exact fix, when the probe failed.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55    fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56        Self {
57            id,
58            class,
59            status: ProbeStatus::Ok,
60            message: message.into(),
61            remediation: None,
62        }
63    }
64
65    fn failed(
66        id: &'static str,
67        class: ProbeClass,
68        message: impl Into<String>,
69        remediation: impl Into<String>,
70    ) -> Self {
71        Self {
72            id,
73            class,
74            status: ProbeStatus::Failed,
75            message: message.into(),
76            remediation: Some(remediation.into()),
77        }
78    }
79}
80
81/// The probes judging the skill installation itself, in catalog order.
82///
83/// Declared here rather than derived by running the catalog: the shared plan
84/// gate's pre-flight phase must name each of these, and the test holding it to
85/// that must not have to spawn a forge CLI or write into the operator's home
86/// to learn what they are.
87pub const SKILL_PROBES: [&str; 1] = ["skill-shadow"];
88
89/// Every executable a Hard probe requires, paired with the nixpkgs package
90/// whose `bin/` supplies it in the installed package's wrapper.
91///
92/// `nix/package.nix` mirrors this list by hand — Nix cannot read this
93/// registry, and generating one list from the other is more machinery than
94/// two entries earn — so the mirror test in `tests/cli.rs` holds the two
95/// lists to agreement and a divergence fails by name instead of shipping.
96pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
97
98/// One owner for the git binary every production launcher spawns.
99///
100/// `RK_GIT_BIN` substitutes it — the same contract every soft tool's
101/// override states, and what lets an operator's own git win over the one
102/// the installed package's wrapper supplies.
103#[must_use]
104pub fn git_bin() -> std::ffi::OsString {
105    crate::requirements::Tool::Git.program()
106}
107
108/// One owner for the nix binary every devshell launch spawns: the lock
109/// refresh, the system probe, and the build. `RK_NIX_BIN` substitutes it.
110#[must_use]
111pub fn nix_bin() -> std::ffi::OsString {
112    crate::requirements::Tool::Nix.program()
113}
114
115/// One owner for the direnv binary, which nothing here spawns but the
116/// doctor probes: it is what loads a consumer's devshell on entry.
117/// `RK_DIRENV_BIN` substitutes it.
118#[must_use]
119pub fn direnv_bin() -> std::ffi::OsString {
120    std::env::var_os("RK_DIRENV_BIN")
121        .filter(|value| !value.is_empty())
122        .unwrap_or_else(|| "direnv".into())
123}
124
125/// Nix answers; `rk self-depend sync` updates and builds the pinned devshell
126/// with it. Soft, and deliberately outside the wrapper: wrapping nix
127/// would put it inside the package's own closure on every host.
128#[must_use]
129pub fn nix() -> ProbeResult {
130    tool(
131        "nix",
132        "RK_NIX_BIN",
133        "nix",
134        "Nix; rk self-depend sync updates and builds the pinned devshell with it",
135        &["--version"],
136    )
137}
138
139/// direnv answers; it loads the devshell on directory entry.
140#[must_use]
141pub fn direnv() -> ProbeResult {
142    tool(
143        "direnv",
144        "RK_DIRENV_BIN",
145        "direnv",
146        "direnv; it loads the devshell on directory entry",
147        &["version"],
148    )
149}
150
151/// One owner for the POSIX shell every setup step spawns through.
152///
153/// `RK_SH_BIN` substitutes it, which is also what keeps tests hermetic on
154/// a host whose `sh` is not the one under test.
155#[must_use]
156pub fn sh_bin() -> std::ffi::OsString {
157    crate::requirements::Tool::Sh.program()
158}
159
160/// One owner for the git-cliff binary the release engine spawns.
161///
162/// `RK_GIT_CLIFF_BIN` substitutes it, the same contract `RK_GIT_BIN`
163/// states. The engine compares the binary's version with the
164/// `versions.toml` pin before it trusts any output.
165#[must_use]
166pub fn git_cliff_bin() -> std::ffi::OsString {
167    crate::requirements::Tool::GitCliff.program()
168}
169
170/// One owner for the uv binary the python driver adapter spawns.
171///
172/// `RK_UV_BIN` substitutes it. The engine runs the uv the job installed,
173/// and uv enforces the target's own `required-version`.
174#[must_use]
175pub fn uv_bin() -> std::ffi::OsString {
176    crate::requirements::Tool::Uv.program()
177}
178
179/// Run the whole catalog, in its stable order.
180#[must_use]
181pub fn run_all() -> Vec<ProbeResult> {
182    let mut probes = vec![
183        shell(),
184        git(),
185        state_root(),
186        skill_shadow(),
187        git_remote(),
188        forge_cli(
189            "gh-auth",
190            "RK_GH_BIN",
191            "gh",
192            "the GitHub CLI",
193            "gh auth login",
194            // `gh auth status` fails when any stored account is broken,
195            // even while the active one works; `--active` judges only the
196            // credential this tool would use, and the gh floor is the
197            // release that added it.
198            &[&["auth", "status", "--active"]],
199        ),
200        forge_cli(
201            "glab-auth",
202            "RK_GLAB_BIN",
203            "glab",
204            "the GitLab CLI",
205            "glab auth login",
206            &[&["auth", "status"]],
207        ),
208        forge_cli_floor(Forge::Github),
209        forge_cli_floor(Forge::Gitlab),
210        registry(),
211        nix(),
212        direnv(),
213        tool(
214            "cosign",
215            "RK_COSIGN_BIN",
216            "cosign",
217            "cosign; the release verify step checks a GitLab provenance bundle with it",
218            &["version"],
219        ),
220        tool(
221            "pypi-attestations",
222            "RK_PYPI_ATTESTATIONS_BIN",
223            "pypi-attestations",
224            "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
225            &["--help"],
226        ),
227    ];
228    probes.extend(hook_probes());
229    probes
230}
231
232/// The hooks of the repository at the current directory, a report for a
233/// project that uses rk: whether the `pre-merge-commit` hook that
234/// `rk integrate` asks git to run is installed, and whether each hook
235/// manager that wrote an installed hook answers. A hook no known manager
236/// wrote is the project's own. Nothing outside a repository, and nothing
237/// here changes what a verb runs.
238fn hook_probes() -> Vec<ProbeResult> {
239    let Ok(cwd) = std::env::current_dir() else {
240        return Vec::new();
241    };
242    let Some(installed) = crate::hooks::installed(&cwd) else {
243        return Vec::new();
244    };
245    let names = |filter: &dyn Fn(crate::hooks::Writer) -> bool| -> Vec<&'static str> {
246        installed
247            .iter()
248            .filter(|hook| filter(hook.writer))
249            .map(|hook| hook.event)
250            .collect()
251    };
252    let mut probes = vec![if installed
253        .iter()
254        .any(|hook| hook.event == "pre-merge-commit")
255    {
256        ProbeResult::ok(
257            "pre-merge-commit-hook",
258            ProbeClass::Soft,
259            "a pre-merge-commit hook is installed, the gate rk integrate asks git to run",
260        )
261    } else {
262        ProbeResult::failed(
263            "pre-merge-commit-hook",
264            ProbeClass::Soft,
265            "no pre-merge-commit hook is installed, so rk integrate has no gate to run in this clone",
266            "install it with the project's hook manager, for example prek install --hook-type pre-merge-commit",
267        )
268    }];
269    for (id, writer) in [
270        ("prek", crate::hooks::Writer::Prek),
271        ("pre-commit", crate::hooks::Writer::PreCommit),
272    ] {
273        let hooks = names(&|found| found == writer);
274        if hooks.is_empty() {
275            continue;
276        }
277        let answers = Command::new(id)
278            .arg("--version")
279            .output()
280            .is_ok_and(|out| out.status.success());
281        let wrote = hooks.join(", ");
282        probes.push(if answers {
283            ProbeResult::ok(id, ProbeClass::Soft, format!("{id} runs; it wrote the {wrote} hooks"))
284        } else {
285            ProbeResult::failed(
286                id,
287                ProbeClass::Soft,
288                format!("{id} does not run, and the {wrote} hooks call it"),
289                format!("install {id} on the host, or reinstall the hooks with the project's hook manager"),
290            )
291        });
292    }
293    let configured = names(&|found| found == crate::hooks::Writer::Configured);
294    if !configured.is_empty() {
295        let mut events = configured.clone();
296        events.dedup();
297        probes.push(ProbeResult::ok(
298            "configured-hooks",
299            ProbeClass::Soft,
300            format!(
301                "git's configuration defines {} hooks for {}",
302                configured.len(),
303                events.join(", ")
304            ),
305        ));
306    }
307    let own = names(&|found| found == crate::hooks::Writer::Own);
308    if !own.is_empty() {
309        probes.push(ProbeResult::ok(
310            "own-hooks",
311            ProbeClass::Soft,
312            format!(
313                "the {} hooks are the project's own, written by no known hook manager",
314                own.join(", ")
315            ),
316        ));
317    }
318    probes
319}
320
321/// A helper binary answers its version call. `env_override` names the
322/// substitute, which is also what keeps tests hermetic; presence is the
323/// whole question, because the tools here take no configuration.
324fn tool(
325    id: &'static str,
326    env_override: &str,
327    default_bin: &str,
328    label: &str,
329    args: &[&str],
330) -> ProbeResult {
331    let bin = std::env::var(env_override)
332        .ok()
333        .filter(|value| !value.is_empty())
334        .unwrap_or_else(|| default_bin.to_owned());
335    match Command::new(&bin).args(args).output() {
336        Ok(out) if out.status.success() => {
337            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
338        }
339        Ok(_) => ProbeResult::failed(
340            id,
341            ProbeClass::Soft,
342            format!("{default_bin} does not answer {}", args.join(" ")),
343            format!("repair {label}"),
344        ),
345        Err(_) => ProbeResult::failed(
346            id,
347            ProbeClass::Soft,
348            format!("{default_bin} is not on PATH"),
349            format!("install {label}"),
350        ),
351    }
352}
353
354/// The crates.io index answers, so `rk versions --check` and
355/// `rk self-depend sync` can compare a pin against the registry. Soft: a
356/// host that cannot reach it is a constraint on the two verbs that fetch,
357/// never a broken install, and every offline verb runs without it.
358fn registry() -> ProbeResult {
359    let id = "registry";
360    let answered = crate::http::send(
361        &crate::http::Request::get(
362            "https://index.crates.io/config.json",
363            std::time::Duration::from_secs(5),
364        )
365        .following(),
366    )
367    .is_ok_and(|answer| answer.ok());
368    if answered {
369        ProbeResult::ok(id, ProbeClass::Soft, "the crates.io index answers")
370    } else {
371        ProbeResult::failed(
372            id,
373            ProbeClass::Soft,
374            "the crates.io index does not answer",
375            "reach the network before rk versions --check or rk self-depend sync; every offline verb runs without it",
376        )
377    }
378}
379
380/// A POSIX shell runs; every setup step spawns through it.
381fn shell() -> ProbeResult {
382    let id = "sh";
383    match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
384        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
385        Ok(status) => ProbeResult::failed(
386            id,
387            ProbeClass::Hard,
388            format!("sh exited {status}"),
389            "repair the POSIX shell on PATH",
390        ),
391        Err(source) => ProbeResult::failed(
392            id,
393            ProbeClass::Hard,
394            format!("sh does not spawn: {source}"),
395            "install a POSIX shell on PATH",
396        ),
397    }
398}
399
400/// Version control answers; every branch, worktree, landing, and setup
401/// verb launches it.
402fn git() -> ProbeResult {
403    let id = "git";
404    match Command::new(git_bin()).arg("--version").output() {
405        Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
406        Ok(_) => ProbeResult::failed(
407            id,
408            ProbeClass::Hard,
409            "git does not answer --version",
410            "repair the git on PATH, or point RK_GIT_BIN at a working one",
411        ),
412        Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
413    }
414}
415
416/// The XDG state root accepts writes; the log and every run journal live
417/// under it.
418fn state_root() -> ProbeResult {
419    let id = "state-root";
420    let Some(root) = crate::applog::state_root() else {
421        return ProbeResult::failed(
422            id,
423            ProbeClass::Hard,
424            "neither XDG_STATE_HOME nor HOME is set",
425            "export HOME, or XDG_STATE_HOME",
426        );
427    };
428    let display = root.display().to_string();
429    let probe = root.join(format!(".probe-{}", std::process::id()));
430    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
431    let _ = std::fs::remove_file(&probe);
432    match written {
433        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
434        Err(source) => ProbeResult::failed(
435            id,
436            ProbeClass::Hard,
437            format!("{display} is not writable: {source}"),
438            format!("make {display} writable"),
439        ),
440    }
441}
442
443/// No user-scope copy of an embedded skill shadows the project's landed
444/// one.
445///
446/// Claude Code resolves a name collision as "personal over project", and
447/// Codex lists both copies, so a `SKILL.md` an earlier release installed
448/// under the home wins over the skills every landing now carries. A folder
449/// without `SKILL.md`, such as one that keeps the operator's notes after
450/// an uninstall, passes. The probe creates nothing.
451///
452/// SATISFIES distribution:the-doctor-names-a-shadowing-user-copy
453fn skill_shadow() -> ProbeResult {
454    let id = SKILL_PROBES[0];
455    let Ok(home) = crate::skills::home() else {
456        return ProbeResult::failed(
457            id,
458            ProbeClass::Soft,
459            "neither HOME nor USERPROFILE is set, so no user-scope skill root resolves",
460            "export HOME",
461        );
462    };
463    let Ok(skills) = crate::skills::all() else {
464        return ProbeResult::failed(
465            id,
466            ProbeClass::Soft,
467            "this binary's embedded skills do not read",
468            "reinstall rk; the sources it was built from are defective",
469        );
470    };
471    let record = Record::load(&home.join(RECORD_PATH));
472    let mut vouched = Vec::new();
473    let mut edited = Vec::new();
474    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
475        for skill in &skills {
476            let manual = home.join(root).join(&skill.name).join("SKILL.md");
477            if std::fs::symlink_metadata(&manual).is_err() {
478                continue;
479            }
480            let ours = std::fs::read(&manual)
481                .is_ok_and(|bytes| record.wrote(&manual, &Digest::of(&bytes)));
482            if ours {
483                vouched.push(manual);
484            } else {
485                edited.push(manual);
486            }
487        }
488    }
489    if let Some(first) = edited.first() {
490        return ProbeResult::failed(
491            id,
492            ProbeClass::Soft,
493            format!(
494                "{} user-scope SKILL.md file(s) shadow the project's landed skills, and the uninstall kept them as your edits, the first at {first}",
495                edited.len()
496            ),
497            format!(
498                "read {first} and each other kept copy, keep what you need elsewhere, and remove them by hand"
499            ),
500        );
501    }
502    if let Some(first) = vouched.first() {
503        return ProbeResult::failed(
504            id,
505            ProbeClass::Soft,
506            format!(
507                "{} user-scope SKILL.md file(s) that an earlier release installed shadow the project's landed skills, the first at {first}",
508                vouched.len()
509            ),
510            "rk skill uninstall --apply",
511        );
512    }
513    ProbeResult::ok(
514        id,
515        ProbeClass::Soft,
516        format!("no user-scope copy of an rk skill under {home} shadows a project's landed one"),
517    )
518}
519
520/// The working directory's `origin` remote parses to a host, which is
521/// what forge and slug detection read.
522fn git_remote() -> ProbeResult {
523    let id = "git-remote";
524    let out = Command::new(git_bin())
525        .args(["remote", "get-url", "origin"])
526        .output();
527    let url = match out {
528        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
529        _ => {
530            return ProbeResult::failed(
531                id,
532                ProbeClass::Soft,
533                "the working directory has no origin remote",
534                "pass --repo <owner/name> where a command needs the slug",
535            );
536        }
537    };
538    // The raw remote never reaches the message: a malformed URL can carry
539    // userinfo — `https://user:token@…` — and a probe result lands in
540    // captured output and CI logs, where a credential must never appear.
541    remote_host(&url).map_or_else(
542        || {
543            ProbeResult::failed(
544                id,
545                ProbeClass::Soft,
546                "the origin remote does not parse to a host",
547                "pass --repo <owner/name> where a command needs the slug",
548            )
549        },
550        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
551    )
552}
553
554/// The host in a git remote URL, for the `scp`-like and URL forms.
555fn remote_host(url: &str) -> Option<String> {
556    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
557        let authority = rest.split('/').next()?;
558        let host = authority
559            .rsplit_once('@')
560            .map_or(authority, |(_, host)| host);
561        let host = host.split(':').next()?;
562        return (!host.is_empty()).then(|| host.to_owned());
563    }
564    let (authority, path) = url.split_once(':')?;
565    let host = authority
566        .rsplit_once('@')
567        .map_or(authority, |(_, host)| host);
568    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
569}
570
571/// A forge CLI is present and authenticated. `env_override` names the
572/// variable that substitutes the binary, which is also what keeps tests
573/// hermetic. `attempts` is tried in order and the first success wins, so
574/// a probe can prefer a sharper flag and still work where the CLI
575/// predates it.
576fn forge_cli(
577    id: &'static str,
578    env_override: &str,
579    default_bin: &str,
580    label: &str,
581    login: &str,
582    attempts: &[&[&str]],
583) -> ProbeResult {
584    let bin = std::env::var(env_override)
585        .ok()
586        .filter(|value| !value.is_empty())
587        .unwrap_or_else(|| default_bin.to_owned());
588    let mut spawned = false;
589    for args in attempts {
590        match Command::new(&bin).args(*args).output() {
591            Ok(out) if out.status.success() => {
592                return ProbeResult::ok(
593                    id,
594                    ProbeClass::Soft,
595                    format!("{default_bin} is authenticated"),
596                );
597            }
598            Ok(_) => spawned = true,
599            Err(_) => {}
600        }
601    }
602    if spawned {
603        ProbeResult::failed(
604            id,
605            ProbeClass::Soft,
606            format!("{default_bin} is not authenticated"),
607            format!("run {login}"),
608        )
609    } else {
610        ProbeResult::failed(
611            id,
612            ProbeClass::Soft,
613            format!("{default_bin} is not on PATH"),
614            format!("install {label}"),
615        )
616    }
617}
618
619/// One owner for a forge CLI's binary name, honoring the override that
620/// keeps the tests hermetic.
621#[must_use]
622pub fn forge_bin(forge: Forge) -> String {
623    crate::requirements::Tool::ForgeCli(forge)
624        .program()
625        .to_string_lossy()
626        .into_owned()
627}
628
629/// The version probe's stable name.
630const fn version_probe_id(forge: Forge) -> &'static str {
631    match forge {
632        Forge::Github => "gh-version",
633        Forge::Gitlab => "glab-version",
634    }
635}
636
637/// The first `<major>.<minor>.<patch>` run in a version line.
638///
639/// `gh version 2.19.0 (2022-10-25)` and `glab 1.114.0 (4d7c6cd)` both
640/// resolve. Nothing else in the crate parses a version string.
641#[must_use]
642pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
643    let bytes = text.as_bytes();
644    let mut start = 0;
645    while start < bytes.len() {
646        if !bytes[start].is_ascii_digit() {
647            start += 1;
648            continue;
649        }
650        let mut end = start;
651        while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
652            end += 1;
653        }
654        let run = &text[start..end];
655        let mut parts = run.split('.');
656        let parsed = (|| {
657            let major = parts.next()?.parse().ok()?;
658            let minor = parts.next()?.parse().ok()?;
659            let patch = parts.next()?.parse().ok()?;
660            Some((major, minor, patch))
661        })();
662        if let Some(version) = parsed {
663            return Some(version);
664        }
665        start = end.max(start + 1);
666    }
667    None
668}
669
670/// Run `<bin> --version` and parse it. A spawn failure, a non-zero exit,
671/// or output carrying no version run all answer `None`, because none of
672/// them proves a version this binary can trust.
673#[must_use]
674pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
675    let out = Command::new(bin).arg("--version").output().ok()?;
676    if !out.status.success() {
677        return None;
678    }
679    parse_cli_version(&String::from_utf8_lossy(&out.stdout))
680}
681
682/// A forge CLI is present and at or above the floor this binary calls.
683///
684/// Soft: a host that never starts work from an issue does not need it.
685fn forge_cli_floor(forge: Forge) -> ProbeResult {
686    let id = version_probe_id(forge);
687    let bin = forge_bin(forge);
688    let name = forge.cli();
689    let floor = forge.cli_floor();
690    match forge_cli_version(&bin) {
691        Some(found) if found >= floor => ProbeResult::ok(
692            id,
693            ProbeClass::Soft,
694            format!("{name} {} is at or above {}", show(found), show(floor)),
695        ),
696        Some(found) => ProbeResult::failed(
697            id,
698            ProbeClass::Soft,
699            format!(
700                "{name} {} is below the {} rk calls",
701                show(found),
702                show(floor)
703            ),
704            forge.cli_upgrade(),
705        ),
706        None => ProbeResult::failed(
707            id,
708            ProbeClass::Soft,
709            format!("{name} does not answer --version with a version"),
710            format!("install {name}"),
711        ),
712    }
713}
714
715/// `<major>.<minor>.<patch>` for a message.
716fn show((major, minor, patch): (u32, u32, u32)) -> String {
717    format!("{major}.{minor}.{patch}")
718}
719
720#[cfg(test)]
721mod tests {
722    use super::{parse_cli_version, remote_host};
723
724    /// Both forge CLIs print their version in a different shape, and a
725    /// line carrying no version resolves to nothing rather than to a
726    /// guess.
727    #[test]
728    fn a_version_line_parses_from_both_forge_clis() {
729        assert_eq!(
730            parse_cli_version("gh version 2.19.0 (2022-10-25)"),
731            Some((2, 19, 0))
732        );
733        assert_eq!(
734            parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
735            Some((1, 114, 0))
736        );
737        assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
738        assert_eq!(parse_cli_version("no version here"), None);
739        assert_eq!(parse_cli_version("gh version 2.19"), None);
740    }
741
742    /// The floor comparison orders by component, so no string comparison
743    /// survives it.
744    #[test]
745    fn a_floor_comparison_orders_by_component() {
746        assert!((2, 100, 0) > (2, 99, 0));
747        assert!((2, 9, 0) < (2, 19, 0));
748        assert!((2, 19, 0) >= (2, 19, 0));
749    }
750
751    #[test]
752    fn a_remote_host_parses_from_both_url_forms() {
753        assert_eq!(
754            remote_host("https://github.com/owner/name.git").as_deref(),
755            Some("github.com")
756        );
757        assert_eq!(
758            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
759            Some("gitlab.com")
760        );
761        assert_eq!(
762            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
763            Some("github.com")
764        );
765        assert_eq!(remote_host("not a url"), None);
766    }
767}