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-sources"];
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 self-depend 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 self-depend 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_sources(),
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 self-depend sync fetch with it",
206            &["--version"],
207        ),
208        registry(),
209        nix(),
210        direnv(),
211        tool(
212            "cosign",
213            "RK_COSIGN_BIN",
214            "cosign",
215            "cosign; the release verify step checks a GitLab provenance bundle with it",
216            &["version"],
217        ),
218        tool(
219            "pypi-attestations",
220            "RK_PYPI_ATTESTATIONS_BIN",
221            "pypi-attestations",
222            "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
223            &["--help"],
224        ),
225    ]
226}
227
228/// A helper binary answers its version call. `env_override` names the
229/// substitute, which is also what keeps tests hermetic; presence is the
230/// whole question, because the tools here take no configuration.
231fn tool(
232    id: &'static str,
233    env_override: &str,
234    default_bin: &str,
235    label: &str,
236    args: &[&str],
237) -> ProbeResult {
238    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
239    match Command::new(&bin).args(args).output() {
240        Ok(out) if out.status.success() => {
241            ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
242        }
243        Ok(_) => ProbeResult::failed(
244            id,
245            ProbeClass::Soft,
246            format!("{default_bin} does not answer {}", args.join(" ")),
247            format!("repair {label}"),
248        ),
249        Err(_) => ProbeResult::failed(
250            id,
251            ProbeClass::Soft,
252            format!("{default_bin} is not on PATH"),
253            format!("install {label}"),
254        ),
255    }
256}
257
258/// The crates.io index answers, so `rk versions --check` and
259/// `rk self-depend sync` can compare a pin against the registry. Soft: a
260/// host that cannot reach it is a constraint on the two verbs that fetch,
261/// never a broken install, and every offline verb runs without it.
262fn registry() -> ProbeResult {
263    let id = "registry";
264    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
265    // Captured rather than inherited, which is what every other probe in
266    // this file does. `curl -S` writes its own message to the stderr it is
267    // handed, so a probe that inherits this process's stream puts a line
268    // like `curl: (28) Resolving timed out` in front of whatever rk writes
269    // there next. On a verb emitting a JSON diagnostic that is a corrupted
270    // machine stream, and the probe already states its verdict in prose.
271    let answered = Command::new(curl)
272        .args([
273            "-fsSL",
274            "--max-time",
275            "5",
276            "https://index.crates.io/config.json",
277        ])
278        .output()
279        .is_ok_and(|answer| answer.status.success());
280    if answered {
281        ProbeResult::ok(id, ProbeClass::Soft, "the crates.io index answers")
282    } else {
283        ProbeResult::failed(
284            id,
285            ProbeClass::Soft,
286            "the crates.io index does not answer",
287            "reach the network before rk versions --check or rk self-depend sync; every offline verb runs without it",
288        )
289    }
290}
291
292/// A POSIX shell runs; every setup step spawns through it.
293fn shell() -> ProbeResult {
294    let id = "sh";
295    match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
296        Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
297        Ok(status) => ProbeResult::failed(
298            id,
299            ProbeClass::Hard,
300            format!("sh exited {status}"),
301            "repair the POSIX shell on PATH",
302        ),
303        Err(source) => ProbeResult::failed(
304            id,
305            ProbeClass::Hard,
306            format!("sh does not spawn: {source}"),
307            "install a POSIX shell on PATH",
308        ),
309    }
310}
311
312/// Version control answers; every branch, worktree, landing, and setup
313/// verb launches it.
314fn git() -> ProbeResult {
315    let id = "git";
316    match Command::new(git_bin()).arg("--version").output() {
317        Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
318        Ok(_) => ProbeResult::failed(
319            id,
320            ProbeClass::Hard,
321            "git does not answer --version",
322            "repair the git on PATH, or point RK_GIT_BIN at a working one",
323        ),
324        Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
325    }
326}
327
328/// The XDG state root accepts writes; the log and every run journal live
329/// under it.
330fn state_root() -> ProbeResult {
331    let id = "state-root";
332    let Some(root) = crate::applog::state_root() else {
333        return ProbeResult::failed(
334            id,
335            ProbeClass::Hard,
336            "neither XDG_STATE_HOME nor HOME is set",
337            "export HOME, or XDG_STATE_HOME",
338        );
339    };
340    let display = root.display().to_string();
341    let probe = root.join(format!(".probe-{}", std::process::id()));
342    let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
343    let _ = std::fs::remove_file(&probe);
344    match written {
345        Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
346        Err(source) => ProbeResult::failed(
347            id,
348            ProbeClass::Hard,
349            format!("{display} is not writable: {source}"),
350            format!("make {display} writable"),
351        ),
352    }
353}
354
355/// The destinations `rk skill install` writes accept writes: the two agent
356/// roots and the shared root, all under the invoking user's home.
357///
358/// A root can exist and still refuse, which is what a read-only bind of an
359/// agent directory produces, so what is tested is the nearest existing
360/// ancestor — the directory an install would actually have to write
361/// through. The probe creates nothing: a preview must still be able to
362/// report a root as absent, and a probe that made it exist would take that
363/// answer away.
364fn skill_roots() -> ProbeResult {
365    let id = SKILL_PROBES[0];
366    let Ok(home) = crate::skills::home() else {
367        return ProbeResult::failed(
368            id,
369            ProbeClass::Soft,
370            "neither HOME nor USERPROFILE is set, so no skill root resolves",
371            "export HOME",
372        );
373    };
374    let mut refused = Vec::new();
375    for root in [CLAUDE_ROOT, AGENTS_ROOT, SHARED_ROOT] {
376        let root = home.join(root);
377        let Some(existing) = nearest_existing(&root) else {
378            refused.push(format!("no ancestor of {root} exists"));
379            continue;
380        };
381        if let Err(source) = accepts_a_write(&existing) {
382            refused.push(format!("{existing} is not writable: {source}"));
383        }
384    }
385    if refused.is_empty() {
386        ProbeResult::ok(
387            id,
388            ProbeClass::Soft,
389            format!("the skill roots under {home} accept writes"),
390        )
391    } else {
392        ProbeResult::failed(
393            id,
394            ProbeClass::Soft,
395            refused.join("; "),
396            format!("make the skill roots under {home} writable"),
397        )
398    }
399}
400
401/// The artifacts every skill shares are installed, and are this binary's.
402///
403/// This is the probe that answers the one failure a shared home produces.
404/// The agent roots and the shared root are separate directories, so a
405/// container, a sandbox, or a sync that carries one and not the other
406/// leaves every skill resolvable by name and unable to read the gates it is
407/// told to read first. A skill that cannot read them runs neither its
408/// pre-flight nor its plan phase, which is the whole reason they are files
409/// rather than prose.
410fn skill_gate() -> ProbeResult {
411    let id = SKILL_PROBES[1];
412    let Ok(home) = crate::skills::home() else {
413        return ProbeResult::failed(
414            id,
415            ProbeClass::Soft,
416            "neither HOME nor USERPROFILE is set, so the shared root does not resolve",
417            "export HOME",
418        );
419    };
420    let root = home.join(SHARED_ROOT);
421    let record = Record::load(&home.join(RECORD_PATH));
422    let planned: Vec<(Utf8PathBuf, &'static [u8])> = crate::skills::shared()
423        .into_iter()
424        .map(|artifact| (root.join(&artifact.path), artifact.bytes))
425        .collect();
426    let found = judge(planned, &record);
427    if let Some(first) = found.missing.first() {
428        return ProbeResult::failed(
429            id,
430            ProbeClass::Soft,
431            format!("a shared artifact every skill reads before acting is not installed: {first}"),
432            "rk skill install --apply",
433        );
434    }
435    if !found.differing.is_empty() {
436        return ProbeResult::failed(
437            id,
438            ProbeClass::Soft,
439            format!(
440                "{} shared artifact(s) under {root} are not this binary's",
441                found.differing.len()
442            ),
443            reinstall(found.all_recorded),
444        );
445    }
446    ProbeResult::ok(
447        id,
448        ProbeClass::Soft,
449        format!("{root} holds this binary's shared artifacts"),
450    )
451}
452
453/// The skills installed under this home are the ones this binary carries.
454///
455/// One binary serves every repository, so a skill under an agent root and
456/// the `rk` on PATH are two artifacts that can be updated apart: a home
457/// shared with a container, a sandbox, or another machine can hold skills
458/// some other build installed. The probe names that drift rather than
459/// leaving an agent to follow instructions the binary no longer answers.
460fn skill_sources() -> ProbeResult {
461    let id = SKILL_PROBES[2];
462    let Ok(home) = crate::skills::home() else {
463        return ProbeResult::failed(
464            id,
465            ProbeClass::Soft,
466            "neither HOME nor USERPROFILE is set, so no agent root resolves",
467            "export HOME",
468        );
469    };
470    let Ok(skills) = crate::skills::all() else {
471        return ProbeResult::failed(
472            id,
473            ProbeClass::Soft,
474            "this binary's embedded skills do not read",
475            "reinstall rk; the sources it was built from are defective",
476        );
477    };
478    let record = Record::load(&home.join(RECORD_PATH));
479    let mut planned = Vec::new();
480    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
481        let root = home.join(root);
482        // An absent agent root is a choice, not a defect: `--agent` selects
483        // one family and leaves the other's root untouched.
484        if !root.is_dir() {
485            continue;
486        }
487        for skill in &skills {
488            planned.push((
489                root.join(&skill.name).join("SKILL.md"),
490                skill.text.as_bytes(),
491            ));
492        }
493    }
494    if planned.is_empty() {
495        return ProbeResult::failed(
496            id,
497            ProbeClass::Soft,
498            format!("no agent skill root exists under {home}"),
499            "rk skill install --apply",
500        );
501    }
502    let found = judge(planned, &record);
503    if let Some(first) = found.missing.first() {
504        return ProbeResult::failed(
505            id,
506            ProbeClass::Soft,
507            format!(
508                "{} of this binary's skills are not installed, the first at {first}",
509                found.missing.len()
510            ),
511            "rk skill install --apply",
512        );
513    }
514    if !found.differing.is_empty() {
515        return ProbeResult::failed(
516            id,
517            ProbeClass::Soft,
518            format!(
519                "{} installed skill(s) are not this binary's; rk is {}",
520                found.differing.len(),
521                env!("CARGO_PKG_VERSION")
522            ),
523            reinstall(found.all_recorded),
524        );
525    }
526    ProbeResult::ok(
527        id,
528        ProbeClass::Soft,
529        format!(
530            "{} installed skill destination(s) are this binary's",
531            found.matching
532        ),
533    )
534}
535
536/// What sits at each destination the embedded skills name.
537struct Installed {
538    /// Destinations the embedded skills name that hold no readable file.
539    missing: Vec<Utf8PathBuf>,
540    /// Destinations holding bytes that are not this binary's.
541    differing: Vec<Utf8PathBuf>,
542    /// How many destinations hold exactly this binary's bytes.
543    matching: usize,
544    /// Whether the record vouches for every differing destination, which
545    /// makes the difference a stale install rather than the operator's own
546    /// edit — and decides whether the fix needs `--force`.
547    all_recorded: bool,
548}
549
550/// Judge each destination the embedded skills name against what sits on disk.
551fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
552    let mut found = Installed {
553        missing: Vec::new(),
554        differing: Vec::new(),
555        matching: 0,
556        all_recorded: true,
557    };
558    for (destination, bytes) in planned {
559        match std::fs::read(&destination) {
560            Ok(held) if held == bytes => found.matching += 1,
561            Ok(held) => {
562                if !record.wrote(&destination, &Digest::of(&held)) {
563                    found.all_recorded = false;
564                }
565                found.differing.push(destination);
566            }
567            Err(_) => found.missing.push(destination),
568        }
569    }
570    found
571}
572
573/// The install that corrects a difference. Bytes the record vouches for are
574/// an older release's and go without asking; bytes it cannot account for are
575/// the operator's own, and overwriting those is what `--force` is.
576const fn reinstall(all_recorded: bool) -> &'static str {
577    if all_recorded {
578        "rk skill install --apply"
579    } else {
580        "rk skill install --apply --force"
581    }
582}
583
584/// The nearest ancestor of `path`, itself included, that exists as a
585/// directory.
586fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
587    let mut current = Some(path);
588    while let Some(dir) = current {
589        if dir.is_dir() {
590            return Some(dir.to_owned());
591        }
592        current = dir.parent();
593    }
594    None
595}
596
597/// A directory accepts a write, leaving nothing behind.
598fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
599    let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
600    let written = std::fs::write(&probe, b"probe");
601    let _ = std::fs::remove_file(&probe);
602    written
603}
604
605/// The working directory's `origin` remote parses to a host, which is
606/// what forge and slug detection read.
607fn git_remote() -> ProbeResult {
608    let id = "git-remote";
609    let out = Command::new(git_bin())
610        .args(["remote", "get-url", "origin"])
611        .output();
612    let url = match out {
613        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
614        _ => {
615            return ProbeResult::failed(
616                id,
617                ProbeClass::Soft,
618                "the working directory has no origin remote",
619                "pass --repo <owner/name> where a command needs the slug",
620            );
621        }
622    };
623    // The raw remote never reaches the message: a malformed URL can carry
624    // userinfo — `https://user:token@…` — and a probe result lands in
625    // captured output and CI logs, where a credential must never appear.
626    remote_host(&url).map_or_else(
627        || {
628            ProbeResult::failed(
629                id,
630                ProbeClass::Soft,
631                "the origin remote does not parse to a host",
632                "pass --repo <owner/name> where a command needs the slug",
633            )
634        },
635        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
636    )
637}
638
639/// The host in a git remote URL, for the `scp`-like and URL forms.
640fn remote_host(url: &str) -> Option<String> {
641    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
642        let authority = rest.split('/').next()?;
643        let host = authority
644            .rsplit_once('@')
645            .map_or(authority, |(_, host)| host);
646        let host = host.split(':').next()?;
647        return (!host.is_empty()).then(|| host.to_owned());
648    }
649    let (authority, path) = url.split_once(':')?;
650    let host = authority
651        .rsplit_once('@')
652        .map_or(authority, |(_, host)| host);
653    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
654}
655
656/// A forge CLI is present and authenticated. `env_override` names the
657/// variable that substitutes the binary, which is also what keeps tests
658/// hermetic. `attempts` is tried in order and the first success wins, so
659/// a probe can prefer a sharper flag and still work where the CLI
660/// predates it.
661fn forge_cli(
662    id: &'static str,
663    env_override: &str,
664    default_bin: &str,
665    label: &str,
666    login: &str,
667    attempts: &[&[&str]],
668) -> ProbeResult {
669    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
670    let mut spawned = false;
671    for args in attempts {
672        match Command::new(&bin).args(*args).output() {
673            Ok(out) if out.status.success() => {
674                return ProbeResult::ok(
675                    id,
676                    ProbeClass::Soft,
677                    format!("{default_bin} is authenticated"),
678                );
679            }
680            Ok(_) => spawned = true,
681            Err(_) => {}
682        }
683    }
684    if spawned {
685        ProbeResult::failed(
686            id,
687            ProbeClass::Soft,
688            format!("{default_bin} is not authenticated"),
689            format!("run {login}"),
690        )
691    } else {
692        ProbeResult::failed(
693            id,
694            ProbeClass::Soft,
695            format!("{default_bin} is not on PATH"),
696            format!("install {label}"),
697        )
698    }
699}
700
701/// One owner for a forge CLI's binary name, honoring the override that
702/// keeps the tests hermetic.
703#[must_use]
704pub fn forge_bin(forge: Forge) -> String {
705    std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
706}
707
708/// The version probe's stable name.
709const fn version_probe_id(forge: Forge) -> &'static str {
710    match forge {
711        Forge::Github => "gh-version",
712        Forge::Gitlab => "glab-version",
713    }
714}
715
716/// The first `<major>.<minor>.<patch>` run in a version line.
717///
718/// `gh version 2.19.0 (2022-10-25)` and `glab 1.114.0 (4d7c6cd)` both
719/// resolve. Nothing else in the crate parses a version string.
720#[must_use]
721pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
722    let bytes = text.as_bytes();
723    let mut start = 0;
724    while start < bytes.len() {
725        if !bytes[start].is_ascii_digit() {
726            start += 1;
727            continue;
728        }
729        let mut end = start;
730        while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
731            end += 1;
732        }
733        let run = &text[start..end];
734        let mut parts = run.split('.');
735        let parsed = (|| {
736            let major = parts.next()?.parse().ok()?;
737            let minor = parts.next()?.parse().ok()?;
738            let patch = parts.next()?.parse().ok()?;
739            Some((major, minor, patch))
740        })();
741        if let Some(version) = parsed {
742            return Some(version);
743        }
744        start = end.max(start + 1);
745    }
746    None
747}
748
749/// Run `<bin> --version` and parse it. A spawn failure, a non-zero exit,
750/// or output carrying no version run all answer `None`, because none of
751/// them proves a version this binary can trust.
752#[must_use]
753pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
754    let out = Command::new(bin).arg("--version").output().ok()?;
755    if !out.status.success() {
756        return None;
757    }
758    parse_cli_version(&String::from_utf8_lossy(&out.stdout))
759}
760
761/// A forge CLI is present and at or above the floor this binary calls.
762///
763/// Soft: a host that never starts work from an issue does not need it.
764fn forge_cli_floor(forge: Forge) -> ProbeResult {
765    let id = version_probe_id(forge);
766    let bin = forge_bin(forge);
767    let name = forge.cli();
768    let floor = forge.cli_floor();
769    match forge_cli_version(&bin) {
770        Some(found) if found >= floor => ProbeResult::ok(
771            id,
772            ProbeClass::Soft,
773            format!("{name} {} is at or above {}", show(found), show(floor)),
774        ),
775        Some(found) => ProbeResult::failed(
776            id,
777            ProbeClass::Soft,
778            format!(
779                "{name} {} is below the {} rk calls",
780                show(found),
781                show(floor)
782            ),
783            forge.cli_upgrade(),
784        ),
785        None => ProbeResult::failed(
786            id,
787            ProbeClass::Soft,
788            format!("{name} does not answer --version with a version"),
789            format!("install {name}"),
790        ),
791    }
792}
793
794/// `<major>.<minor>.<patch>` for a message.
795fn show((major, minor, patch): (u32, u32, u32)) -> String {
796    format!("{major}.{minor}.{patch}")
797}
798
799/// The gate a verb runs before its first forge call: the CLI is present
800/// and at or above [`Forge::cli_floor`]. Returns the binary to spawn and
801/// the version found.
802///
803/// It runs before anything is written, locally or remotely, so a stale CLI
804/// costs one local process and leaves the clone untouched.
805///
806/// # Errors
807///
808/// [`Reason::PrerequisiteUnmet`] where the CLI is absent, does not answer,
809/// or is below the floor.
810pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
811    let bin = forge_bin(forge);
812    let name = forge.cli();
813    let floor = forge.cli_floor();
814    let Some(found) = forge_cli_version(&bin) else {
815        return Err(RkError::refusal(
816            Diagnostic::new(
817                Reason::PrerequisiteUnmet,
818                format!("{name} does not answer --version with a version"),
819            )
820            .expected(format!("{name} at or above {} on PATH", show(floor)))
821            .action(format!("install {name}, then rerun"))
822            .target_state("unchanged"),
823        ));
824    };
825    if found < floor {
826        return Err(RkError::refusal(
827            Diagnostic::new(
828                Reason::PrerequisiteUnmet,
829                format!(
830                    "{name} {} is below the {} rk calls",
831                    show(found),
832                    show(floor)
833                ),
834            )
835            .expected(format!("{name} at or above {}", show(floor)))
836            .action(forge.cli_upgrade())
837            .target_state("unchanged"),
838        ));
839    }
840    Ok((bin, found))
841}
842
843#[cfg(test)]
844mod tests {
845    use super::{parse_cli_version, remote_host};
846
847    /// Both forge CLIs print their version in a different shape, and a
848    /// line carrying no version resolves to nothing rather than to a
849    /// guess.
850    #[test]
851    fn a_version_line_parses_from_both_forge_clis() {
852        assert_eq!(
853            parse_cli_version("gh version 2.19.0 (2022-10-25)"),
854            Some((2, 19, 0))
855        );
856        assert_eq!(
857            parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
858            Some((1, 114, 0))
859        );
860        assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
861        assert_eq!(parse_cli_version("no version here"), None);
862        assert_eq!(parse_cli_version("gh version 2.19"), None);
863    }
864
865    /// The floor comparison orders by component, so no string comparison
866    /// survives it.
867    #[test]
868    fn a_floor_comparison_orders_by_component() {
869        assert!((2, 100, 0) > (2, 99, 0));
870        assert!((2, 9, 0) < (2, 19, 0));
871        assert!((2, 19, 0) >= (2, 19, 0));
872    }
873
874    #[test]
875    fn a_remote_host_parses_from_both_url_forms() {
876        assert_eq!(
877            remote_host("https://github.com/owner/name.git").as_deref(),
878            Some("github.com")
879        );
880        assert_eq!(
881            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
882            Some("gitlab.com")
883        );
884        assert_eq!(
885            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
886            Some("github.com")
887        );
888        assert_eq!(remote_host("not a url"), None);
889    }
890}