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 camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::detect::Forge;
16use crate::diagnostic::{Diagnostic, Reason};
17use crate::error::RkError;
18use crate::skills::record::{RECORD_PATH, Record};
19use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest, SHARED_ROOT};
20
21/// How a failure weighs at the doctor level.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum ProbeClass {
25    /// No mutating command can work without this.
26    Hard,
27    /// Needed only by some commands or some forges.
28    Soft,
29}
30
31/// What a probe found.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum ProbeStatus {
35    /// The probe passed.
36    Ok,
37    /// The probe failed; the remediation says what fixes it.
38    Failed,
39}
40
41/// One probe's answer.
42#[derive(Debug, Serialize)]
43pub struct ProbeResult {
44    /// The probe's stable name.
45    pub id: &'static str,
46    /// How the failure weighs.
47    pub class: ProbeClass,
48    /// What was found.
49    pub status: ProbeStatus,
50    /// What was found, one line.
51    pub message: String,
52    /// The exact fix, when the probe failed.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub remediation: Option<String>,
55}
56
57impl ProbeResult {
58    fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
59        Self {
60            id,
61            class,
62            status: ProbeStatus::Ok,
63            message: message.into(),
64            remediation: None,
65        }
66    }
67
68    fn failed(
69        id: &'static str,
70        class: ProbeClass,
71        message: impl Into<String>,
72        remediation: impl Into<String>,
73    ) -> Self {
74        Self {
75            id,
76            class,
77            status: ProbeStatus::Failed,
78            message: message.into(),
79            remediation: Some(remediation.into()),
80        }
81    }
82}
83
84/// The probes judging the skill installation itself, in catalog order.
85///
86/// Declared here rather than derived by running the catalog: the shared plan
87/// gate's pre-flight phase must name each of these, and the test holding it to
88/// that must not have to spawn a forge CLI or write into the operator's home
89/// to learn what they are.
90pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-payload"];
91
92/// Every executable a Hard probe requires, paired with the nixpkgs package
93/// whose `bin/` supplies it in the installed package's wrapper.
94///
95/// `nix/package.nix` mirrors this list by hand — Nix cannot read this
96/// registry, and generating one list from the other is more machinery than
97/// two entries earn — so the mirror test in `tests/cli.rs` holds the two
98/// lists to agreement and a divergence fails by name instead of shipping.
99pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
100
101/// One owner for the git binary every production launcher spawns.
102///
103/// `RK_GIT_BIN` substitutes it — the same contract every soft tool's
104/// override states, and what lets an operator's own git win over the one
105/// the installed package's wrapper supplies.
106#[must_use]
107pub fn git_bin() -> std::ffi::OsString {
108    std::env::var_os("RK_GIT_BIN").unwrap_or_else(|| "git".into())
109}
110
111/// One owner for the nix binary every devshell launch spawns: the lock
112/// refresh, the system probe, and the build. `RK_NIX_BIN` substitutes it.
113#[must_use]
114pub fn nix_bin() -> std::ffi::OsString {
115    std::env::var_os("RK_NIX_BIN").unwrap_or_else(|| "nix".into())
116}
117
118/// One owner for the direnv binary, which nothing here spawns but the
119/// doctor probes: it is what loads a consumer's devshell on entry.
120/// `RK_DIRENV_BIN` substitutes it.
121#[must_use]
122pub fn direnv_bin() -> std::ffi::OsString {
123    std::env::var_os("RK_DIRENV_BIN").unwrap_or_else(|| "direnv".into())
124}
125
126/// Nix answers; `rk devshell sync` updates and builds the pinned devshell
127/// with it. Soft, and deliberately outside the wrapper: wrapping nix
128/// would put it inside the package's own closure on every host.
129#[must_use]
130pub fn nix() -> ProbeResult {
131    tool(
132        "nix",
133        "RK_NIX_BIN",
134        "nix",
135        "Nix; rk devshell sync updates and builds the pinned devshell with it",
136        &["--version"],
137    )
138}
139
140/// direnv answers; it loads the devshell on directory entry.
141#[must_use]
142pub fn direnv() -> ProbeResult {
143    tool(
144        "direnv",
145        "RK_DIRENV_BIN",
146        "direnv",
147        "direnv; it loads the devshell on directory entry",
148        &["version"],
149    )
150}
151
152/// One owner for the POSIX shell every setup step spawns through.
153///
154/// `RK_SH_BIN` substitutes it, which is also what keeps tests hermetic on
155/// a host whose `sh` is not the one under test.
156#[must_use]
157pub fn sh_bin() -> std::ffi::OsString {
158    std::env::var_os("RK_SH_BIN").unwrap_or_else(|| "sh".into())
159}
160
161/// Run the whole catalog, in its stable order.
162#[must_use]
163pub fn run_all() -> Vec<ProbeResult> {
164    vec![
165        shell(),
166        git(),
167        state_root(),
168        skill_roots(),
169        skill_gate(),
170        skill_payload(),
171        git_remote(),
172        forge_cli(
173            "gh-auth",
174            "RK_GH_BIN",
175            "gh",
176            "the GitHub CLI",
177            "gh auth login",
178            // `gh auth status` fails when any stored account is broken,
179            // even while the active one works; `--active` judges only the
180            // credential this tool would use. Older gh lacks the flag, so
181            // the bare form is the fallback.
182            &[&["auth", "status", "--active"], &["auth", "status"]],
183        ),
184        forge_cli(
185            "glab-auth",
186            "RK_GLAB_BIN",
187            "glab",
188            "the GitLab CLI",
189            "glab auth login",
190            &[&["auth", "status"]],
191        ),
192        forge_cli_floor(Forge::Github),
193        forge_cli_floor(Forge::Gitlab),
194        tool(
195            "openssl",
196            "RK_OPENSSL_BIN",
197            "openssl",
198            "OpenSSL; install-bot signs the App JWT with it",
199            &["version"],
200        ),
201        tool(
202            "curl",
203            "RK_CURL_BIN",
204            "curl",
205            "curl; install-bot reads the installation, and rk versions --check and rk devshell sync fetch with it",
206            &["--version"],
207        ),
208        nix(),
209        direnv(),
210        tool(
211            "cosign",
212            "RK_COSIGN_BIN",
213            "cosign",
214            "cosign; the release verify step checks a GitLab provenance bundle with it",
215            &["version"],
216        ),
217        tool(
218            "pypi-attestations",
219            "RK_PYPI_ATTESTATIONS_BIN",
220            "pypi-attestations",
221            "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
222            &["--help"],
223        ),
224    ]
225}
226
227/// A helper binary answers its version call. `env_override` names the
228/// substitute, which is also what keeps tests hermetic; presence is the
229/// whole question, because the tools here take no configuration.
230fn tool(
231    id: &'static str,
232    env_override: &str,
233    default_bin: &str,
234    label: &str,
235    args: &[&str],
236) -> ProbeResult {
237    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
238    match Command::new(&bin).args(args).output() {
239        Ok(out) if out.status.success() => {
240            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
241        }
242        Ok(_) => ProbeResult::failed(
243            id,
244            ProbeClass::Soft,
245            format!("{default_bin} does not answer {}", args.join(" ")),
246            format!("repair {label}"),
247        ),
248        Err(_) => ProbeResult::failed(
249            id,
250            ProbeClass::Soft,
251            format!("{default_bin} is not on PATH"),
252            format!("install {label}"),
253        ),
254    }
255}
256
257/// A POSIX shell runs; every setup step spawns through it.
258fn shell() -> ProbeResult {
259    let id = "sh";
260    match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
261        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
262        Ok(status) => ProbeResult::failed(
263            id,
264            ProbeClass::Hard,
265            format!("sh exited {status}"),
266            "repair the POSIX shell on PATH",
267        ),
268        Err(source) => ProbeResult::failed(
269            id,
270            ProbeClass::Hard,
271            format!("sh does not spawn: {source}"),
272            "install a POSIX shell on PATH",
273        ),
274    }
275}
276
277/// Version control answers; every branch, worktree, landing, and setup
278/// verb launches it.
279fn git() -> ProbeResult {
280    let id = "git";
281    match Command::new(git_bin()).arg("--version").output() {
282        Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
283        Ok(_) => ProbeResult::failed(
284            id,
285            ProbeClass::Hard,
286            "git does not answer --version",
287            "repair the git on PATH, or point RK_GIT_BIN at a working one",
288        ),
289        Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
290    }
291}
292
293/// The XDG state root accepts writes; the log and every run journal live
294/// under it.
295fn state_root() -> ProbeResult {
296    let id = "state-root";
297    let Some(root) = crate::applog::state_root() else {
298        return ProbeResult::failed(
299            id,
300            ProbeClass::Hard,
301            "neither XDG_STATE_HOME nor HOME is set",
302            "export HOME, or XDG_STATE_HOME",
303        );
304    };
305    let display = root.display().to_string();
306    let probe = root.join(format!(".probe-{}", std::process::id()));
307    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
308    let _ = std::fs::remove_file(&probe);
309    match written {
310        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
311        Err(source) => ProbeResult::failed(
312            id,
313            ProbeClass::Hard,
314            format!("{display} is not writable: {source}"),
315            format!("make {display} writable"),
316        ),
317    }
318}
319
320/// The destinations `rk skill install` writes accept writes: the two agent
321/// roots and the shared root, all under the invoking user's home.
322///
323/// A root can exist and still refuse, which is what a read-only bind of an
324/// agent directory produces, so what is tested is the nearest existing
325/// ancestor — the directory an install would actually have to write
326/// through. The probe creates nothing: a preview must still be able to
327/// report a root as absent, and a probe that made it exist would take that
328/// answer away.
329fn skill_roots() -> ProbeResult {
330    let id = SKILL_PROBES[0];
331    let Ok(home) = crate::skills::home() else {
332        return ProbeResult::failed(
333            id,
334            ProbeClass::Soft,
335            "neither HOME nor USERPROFILE is set, so no skill root resolves",
336            "export HOME",
337        );
338    };
339    let mut refused = Vec::new();
340    for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
341        let root = home.join(root);
342        let Some(existing) = nearest_existing(&root) else {
343            refused.push(format!("no ancestor of {root} exists"));
344            continue;
345        };
346        if let Err(source) = accepts_a_write(&existing) {
347            refused.push(format!("{existing} is not writable: {source}"));
348        }
349    }
350    if refused.is_empty() {
351        ProbeResult::ok(
352            id,
353            ProbeClass::Soft,
354            format!("the skill roots under {home} accept writes"),
355        )
356    } else {
357        ProbeResult::failed(
358            id,
359            ProbeClass::Soft,
360            refused.join("; "),
361            format!("make the skill roots under {home} writable"),
362        )
363    }
364}
365
366/// The artifacts every skill shares are installed, and are this binary's.
367///
368/// This is the probe that answers the one failure a shared home produces.
369/// The agent roots and the shared root are separate directories, so a
370/// container, a sandbox, or a sync that carries one and not the other
371/// leaves every skill resolvable by name and unable to read the gates it is
372/// told to read first. A skill that cannot read them runs neither its
373/// pre-flight nor its plan phase, which is the whole reason they are files
374/// rather than prose.
375fn skill_gate() -> ProbeResult {
376    let id = SKILL_PROBES[1];
377    let Ok(home) = crate::skills::home() else {
378        return ProbeResult::failed(
379            id,
380            ProbeClass::Soft,
381            "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
382            "export HOME",
383        );
384    };
385    let root = home.join(SHARED_ROOT);
386    let record = Record::load(&home.join(RECORD_PATH));
387    let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
388        .into_iter()
389        .map(|artifact| (root.join(&artifact.path), artifact.bytes))
390        .collect();
391    let found = judge(planned, &record);
392    if let Some(first) = found.missing.first() {
393        return ProbeResult::failed(
394            id,
395            ProbeClass::Soft,
396            format!("a shared artifact every skill reads before acting is not installed: {first}"),
397            "rk skill install --apply",
398        );
399    }
400    if !found.differing.is_empty() {
401        return ProbeResult::failed(
402            id,
403            ProbeClass::Soft,
404            format!(
405                "{} shared artifact(s) under {root} are not this binary's",
406                found.differing.len()
407            ),
408            reinstall(found.all_recorded),
409        );
410    }
411    ProbeResult::ok(
412        id,
413        ProbeClass::Soft,
414        format!("{root} holds this binary's shared artifacts"),
415    )
416}
417
418/// The skills installed under this home are the ones this binary carries.
419///
420/// One binary serves every repository, so a skill under an agent root and
421/// the `rk` on PATH are two artifacts that can be updated apart: a home
422/// shared with a container, a sandbox, or another machine can hold skills
423/// some other build installed. The probe names that drift rather than
424/// leaving an agent to follow instructions the binary no longer answers.
425fn skill_payload() -> ProbeResult {
426    let id = SKILL_PROBES[2];
427    let Ok(home) = crate::skills::home() else {
428        return ProbeResult::failed(
429            id,
430            ProbeClass::Soft,
431            "neither HOME nor USERPROFILE is set, so no agent root resolves",
432            "export HOME",
433        );
434    };
435    let Ok(skills) = crate::skills::all() else {
436        return ProbeResult::failed(
437            id,
438            ProbeClass::Soft,
439            "this binary's embedded skills do not read",
440            "reinstall rk; the payload it was built from is defective",
441        );
442    };
443    let record = Record::load(&home.join(RECORD_PATH));
444    let mut planned = Vec::new();
445    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
446        let root = home.join(root);
447        // An absent agent root is a choice, not a defect: `--agent` selects
448        // one family and leaves the other's root untouched.
449        if !root.is_dir() {
450            continue;
451        }
452        for skill in &skills {
453            planned.push((
454                root.join(&skill.name).join("SKILL.md"),
455                skill.text.as_bytes(),
456            ));
457        }
458    }
459    if planned.is_empty() {
460        return ProbeResult::failed(
461            id,
462            ProbeClass::Soft,
463            format!("no agent skill root exists under {home}"),
464            "rk skill install --apply",
465        );
466    }
467    let found = judge(planned, &record);
468    if let Some(first) = found.missing.first() {
469        return ProbeResult::failed(
470            id,
471            ProbeClass::Soft,
472            format!(
473                "{} of this binary's skills are not installed, the first at {first}",
474                found.missing.len()
475            ),
476            "rk skill install --apply",
477        );
478    }
479    if !found.differing.is_empty() {
480        return ProbeResult::failed(
481            id,
482            ProbeClass::Soft,
483            format!(
484                "{} installed skill(s) are not this binary's; rk is {}",
485                found.differing.len(),
486                env!("CARGO_PKG_VERSION")
487            ),
488            reinstall(found.all_recorded),
489        );
490    }
491    ProbeResult::ok(
492        id,
493        ProbeClass::Soft,
494        format!(
495            "{} installed skill destination(s) are this binary's",
496            found.matching
497        ),
498    )
499}
500
501/// What sits at each destination the payload names.
502struct Installed {
503    /// Destinations the payload names that hold no readable file.
504    missing: Vec<Utf8PathBuf>,
505    /// Destinations holding bytes that are not this binary's.
506    differing: Vec<Utf8PathBuf>,
507    /// How many destinations hold exactly this binary's bytes.
508    matching: usize,
509    /// Whether the record vouches for every differing destination, which
510    /// makes the difference a stale install rather than the operator's own
511    /// edit — and decides whether the fix needs `--force`.
512    all_recorded: bool,
513}
514
515/// Judge each destination the payload names against what sits on disk.
516fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
517    let mut found = Installed {
518        missing: Vec::new(),
519        differing: Vec::new(),
520        matching: 0,
521        all_recorded: true,
522    };
523    for (destination, bytes) in planned {
524        match std::fs::read(&destination) {
525            Ok(held) if held == bytes => found.matching += 1,
526            Ok(held) => {
527                if !record.wrote(&destination, &Digest::of(&held)) {
528                    found.all_recorded = false;
529                }
530                found.differing.push(destination);
531            }
532            Err(_) => found.missing.push(destination),
533        }
534    }
535    found
536}
537
538/// The install that corrects a difference. Bytes the record vouches for are
539/// an older release's and go without asking; bytes it cannot account for are
540/// the operator's own, and overwriting those is what `--force` is.
541const fn reinstall(all_recorded: bool) -> &'static str {
542    if all_recorded {
543        "rk skill install --apply"
544    } else {
545        "rk skill install --apply --force"
546    }
547}
548
549/// The nearest ancestor of `path`, itself included, that exists as a
550/// directory.
551fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
552    let mut current = Some(path);
553    while let Some(dir) = current {
554        if dir.is_dir() {
555            return Some(dir.to_owned());
556        }
557        current = dir.parent();
558    }
559    None
560}
561
562/// A directory accepts a write, leaving nothing behind.
563fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
564    let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
565    let written = std::fs::write(&probe, b"probe");
566    let _ = std::fs::remove_file(&probe);
567    written
568}
569
570/// The working directory's `origin` remote parses to a host, which is
571/// what forge and slug detection read.
572fn git_remote() -> ProbeResult {
573    let id = "git-remote";
574    let out = Command::new(git_bin())
575        .args(["remote", "get-url", "origin"])
576        .output();
577    let url = match out {
578        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
579        _ => {
580            return ProbeResult::failed(
581                id,
582                ProbeClass::Soft,
583                "the working directory has no origin remote",
584                "pass --repo <owner/name> where a command needs the slug",
585            );
586        }
587    };
588    // The raw remote never reaches the message: a malformed URL can carry
589    // userinfo — `https://user:token@…` — and a probe result lands in
590    // captured output and CI logs, where a credential must never appear.
591    remote_host(&url).map_or_else(
592        || {
593            ProbeResult::failed(
594                id,
595                ProbeClass::Soft,
596                "the origin remote does not parse to a host",
597                "pass --repo <owner/name> where a command needs the slug",
598            )
599        },
600        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
601    )
602}
603
604/// The host in a git remote URL, for the `scp`-like and URL forms.
605fn remote_host(url: &str) -> Option<String> {
606    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
607        let authority = rest.split('/').next()?;
608        let host = authority
609            .rsplit_once('@')
610            .map_or(authority, |(_, host)| host);
611        let host = host.split(':').next()?;
612        return (!host.is_empty()).then(|| host.to_owned());
613    }
614    let (authority, path) = url.split_once(':')?;
615    let host = authority
616        .rsplit_once('@')
617        .map_or(authority, |(_, host)| host);
618    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
619}
620
621/// A forge CLI is present and authenticated. `env_override` names the
622/// variable that substitutes the binary, which is also what keeps tests
623/// hermetic. `attempts` is tried in order and the first success wins, so
624/// a probe can prefer a sharper flag and still work where the CLI
625/// predates it.
626fn forge_cli(
627    id: &'static str,
628    env_override: &str,
629    default_bin: &str,
630    label: &str,
631    login: &str,
632    attempts: &[&[&str]],
633) -> ProbeResult {
634    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
635    let mut spawned = false;
636    for args in attempts {
637        match Command::new(&bin).args(*args).output() {
638            Ok(out) if out.status.success() => {
639                return ProbeResult::ok(
640                    id,
641                    ProbeClass::Soft,
642                    format!("{default_bin} is authenticated"),
643                );
644            }
645            Ok(_) => spawned = true,
646            Err(_) => {}
647        }
648    }
649    if spawned {
650        ProbeResult::failed(
651            id,
652            ProbeClass::Soft,
653            format!("{default_bin} is not authenticated"),
654            format!("run {login}"),
655        )
656    } else {
657        ProbeResult::failed(
658            id,
659            ProbeClass::Soft,
660            format!("{default_bin} is not on PATH"),
661            format!("install {label}"),
662        )
663    }
664}
665
666/// One owner for a forge CLI's binary name, honoring the override that
667/// keeps the tests hermetic.
668#[must_use]
669pub fn forge_bin(forge: Forge) -> String {
670    std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
671}
672
673/// The version probe's stable name.
674const fn version_probe_id(forge: Forge) -> &'static str {
675    match forge {
676        Forge::Github => "gh-version",
677        Forge::Gitlab => "glab-version",
678    }
679}
680
681/// The first `<major>.<minor>.<patch>` run in a version line.
682///
683/// `gh version 2.19.0 (2022-10-25)` and `glab 1.114.0 (4d7c6cd)` both
684/// resolve. Nothing else in the crate parses a version string.
685#[must_use]
686pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
687    let bytes = text.as_bytes();
688    let mut start = 0;
689    while start < bytes.len() {
690        if !bytes[start].is_ascii_digit() {
691            start += 1;
692            continue;
693        }
694        let mut end = start;
695        while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
696            end += 1;
697        }
698        let run = &text[start..end];
699        let mut parts = run.split('.');
700        let parsed = (|| {
701            let major = parts.next()?.parse().ok()?;
702            let minor = parts.next()?.parse().ok()?;
703            let patch = parts.next()?.parse().ok()?;
704            Some((major, minor, patch))
705        })();
706        if let Some(version) = parsed {
707            return Some(version);
708        }
709        start = end.max(start + 1);
710    }
711    None
712}
713
714/// Run `<bin> --version` and parse it. A spawn failure, a non-zero exit,
715/// or output carrying no version run all answer `None`, because none of
716/// them proves a version this binary can trust.
717#[must_use]
718pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
719    let out = Command::new(bin).arg("--version").output().ok()?;
720    if !out.status.success() {
721        return None;
722    }
723    parse_cli_version(&String::from_utf8_lossy(&out.stdout))
724}
725
726/// A forge CLI is present and at or above the floor this binary calls.
727///
728/// Soft: a host that never starts work from an issue does not need it.
729fn forge_cli_floor(forge: Forge) -> ProbeResult {
730    let id = version_probe_id(forge);
731    let bin = forge_bin(forge);
732    let name = forge.cli();
733    let floor = forge.cli_floor();
734    match forge_cli_version(&bin) {
735        Some(found) if found >= floor => ProbeResult::ok(
736            id,
737            ProbeClass::Soft,
738            format!("{name} {} is at or above {}", show(found), show(floor)),
739        ),
740        Some(found) => ProbeResult::failed(
741            id,
742            ProbeClass::Soft,
743            format!(
744                "{name} {} is below the {} rk calls",
745                show(found),
746                show(floor)
747            ),
748            forge.cli_upgrade(),
749        ),
750        None => ProbeResult::failed(
751            id,
752            ProbeClass::Soft,
753            format!("{name} does not answer --version with a version"),
754            format!("install {name}"),
755        ),
756    }
757}
758
759/// `<major>.<minor>.<patch>` for a message.
760fn show((major, minor, patch): (u32, u32, u32)) -> String {
761    format!("{major}.{minor}.{patch}")
762}
763
764/// The gate a verb runs before its first forge call: the CLI is present
765/// and at or above [`Forge::cli_floor`]. Returns the binary to spawn and
766/// the version found.
767///
768/// It runs before anything is written, locally or remotely, so a stale CLI
769/// costs one local process and leaves the clone untouched.
770///
771/// # Errors
772///
773/// [`Reason::PrerequisiteUnmet`] where the CLI is absent, does not answer,
774/// or is below the floor.
775pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
776    let bin = forge_bin(forge);
777    let name = forge.cli();
778    let floor = forge.cli_floor();
779    let Some(found) = forge_cli_version(&bin) else {
780        return Err(RkError::refusal(
781            Diagnostic::new(
782                Reason::PrerequisiteUnmet,
783                format!("{name} does not answer --version with a version"),
784            )
785            .expected(format!("{name} at or above {} on PATH", show(floor)))
786            .action(format!("install {name}, then rerun"))
787            .target_state("unchanged"),
788        ));
789    };
790    if found < floor {
791        return Err(RkError::refusal(
792            Diagnostic::new(
793                Reason::PrerequisiteUnmet,
794                format!(
795                    "{name} {} is below the {} rk calls",
796                    show(found),
797                    show(floor)
798                ),
799            )
800            .expected(format!("{name} at or above {}", show(floor)))
801            .action(forge.cli_upgrade())
802            .target_state("unchanged"),
803        ));
804    }
805    Ok((bin, found))
806}
807
808#[cfg(test)]
809mod tests {
810    use super::{parse_cli_version, remote_host};
811
812    /// Both forge CLIs print their version in a different shape, and a
813    /// line carrying no version resolves to nothing rather than to a
814    /// guess.
815    #[test]
816    fn a_version_line_parses_from_both_forge_clis() {
817        assert_eq!(
818            parse_cli_version("gh version 2.19.0 (2022-10-25)"),
819            Some((2, 19, 0))
820        );
821        assert_eq!(
822            parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
823            Some((1, 114, 0))
824        );
825        assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
826        assert_eq!(parse_cli_version("no version here"), None);
827        assert_eq!(parse_cli_version("gh version 2.19"), None);
828    }
829
830    /// The floor comparison orders by component, so no string comparison
831    /// survives it.
832    #[test]
833    fn a_floor_comparison_orders_by_component() {
834        assert!((2, 100, 0) > (2, 99, 0));
835        assert!((2, 9, 0) < (2, 19, 0));
836        assert!((2, 19, 0) >= (2, 19, 0));
837    }
838
839    #[test]
840    fn a_remote_host_parses_from_both_url_forms() {
841        assert_eq!(
842            remote_host("https://github.com/owner/name.git").as_deref(),
843            Some("github.com")
844        );
845        assert_eq!(
846            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
847            Some("gitlab.com")
848        );
849        assert_eq!(
850            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
851            Some("github.com")
852        );
853        assert_eq!(remote_host("not a url"), None);
854    }
855}