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};
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 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] {
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/// Every installed package carries the gates, and they are this binary's.
402///
403/// This is the probe that answers the one failure a package without its
404/// gates produces: the skill stays resolvable by name and cannot read the
405/// gates it is told to read first. A skill that cannot read them runs
406/// neither its pre-flight nor its plan phase, which is the whole reason they
407/// are files rather than prose.
408fn skill_gate() -> ProbeResult {
409    let id = SKILL_PROBES[1];
410    let Some(context) = installed_context(id) else {
411        return no_home(id);
412    };
413    let (home, packages, record) = match context {
414        Ok(context) => context,
415        Err(failed) => return failed,
416    };
417    let planned = installed_files(&home, &packages, true);
418    if planned.is_empty() {
419        return ProbeResult::failed(
420            id,
421            ProbeClass::Soft,
422            format!("no agent skill root exists under {home}"),
423            "rk skill install --apply",
424        );
425    }
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 gate every skill reads before acting is missing from its package: {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                "{} installed gate copies are not this binary's; rk is {}",
441                found.differing.len(),
442                env!("CARGO_PKG_VERSION")
443            ),
444            reinstall(found.all_recorded),
445        );
446    }
447    ProbeResult::ok(
448        id,
449        ProbeClass::Soft,
450        format!("{} installed gate copies are this binary's", found.matching),
451    )
452}
453
454/// The skills installed under this home are the ones this binary carries.
455///
456/// One binary serves every repository, so a skill under an agent root and
457/// the `rk` on PATH are two artifacts that can be updated apart: a home
458/// shared with a container, a sandbox, or another machine can hold skills
459/// some other build installed. The probe names that drift rather than
460/// leaving an agent to follow instructions the binary no longer answers.
461/// It judges each `SKILL.md` and the skill's own reference files, and leaves
462/// the gate copies to `skill-gate`.
463fn skill_sources() -> ProbeResult {
464    let id = SKILL_PROBES[2];
465    let Some(context) = installed_context(id) else {
466        return no_home(id);
467    };
468    let (home, packages, record) = match context {
469        Ok(context) => context,
470        Err(failed) => return failed,
471    };
472    let planned = installed_files(&home, &packages, false);
473    if planned.is_empty() {
474        return ProbeResult::failed(
475            id,
476            ProbeClass::Soft,
477            format!("no agent skill root exists under {home}"),
478            "rk skill install --apply",
479        );
480    }
481    let found = judge(planned, &record);
482    if let Some(first) = found.missing.first() {
483        return ProbeResult::failed(
484            id,
485            ProbeClass::Soft,
486            format!(
487                "{} of this binary's skill files are not installed, the first at {first}",
488                found.missing.len()
489            ),
490            "rk skill install --apply",
491        );
492    }
493    if !found.differing.is_empty() {
494        return ProbeResult::failed(
495            id,
496            ProbeClass::Soft,
497            format!(
498                "{} installed skill file(s) are not this binary's; rk is {}",
499                found.differing.len(),
500                env!("CARGO_PKG_VERSION")
501            ),
502            reinstall(found.all_recorded),
503        );
504    }
505    ProbeResult::ok(
506        id,
507        ProbeClass::Soft,
508        format!(
509            "{} installed skill destination(s) are this binary's",
510            found.matching
511        ),
512    )
513}
514
515/// What both skill probes read: the home, the embedded packages, and the
516/// record.
517type SkillContext = (Utf8PathBuf, Vec<crate::skills::Package>, Record);
518
519/// The home, the embedded packages, and the record both skill probes read,
520/// or the failure that stops them: `None` when no home resolves.
521fn installed_context(id: &'static str) -> Option<Result<SkillContext, ProbeResult>> {
522    let home = crate::skills::home().ok()?;
523    let Ok(packages) = crate::skills::packages() else {
524        return Some(Err(ProbeResult::failed(
525            id,
526            ProbeClass::Soft,
527            "this binary's embedded skills do not read",
528            "reinstall rk; the sources it was built from are defective",
529        )));
530    };
531    let record = Record::load(&home.join(RECORD_PATH));
532    Some(Ok((home, packages, record)))
533}
534
535/// The probe result when no home directory resolves.
536fn no_home(id: &'static str) -> ProbeResult {
537    ProbeResult::failed(
538        id,
539        ProbeClass::Soft,
540        "neither HOME nor USERPROFILE is set, so no agent root resolves",
541        "export HOME",
542    )
543}
544
545/// Every package file under each agent root that exists: the gate copies
546/// when `shared` is set, the skill's own files otherwise.
547///
548/// An absent agent root is a choice, not a defect: `--agent` selects one
549/// family and leaves the other's root untouched.
550fn installed_files(
551    home: &Utf8Path,
552    packages: &[crate::skills::Package],
553    shared: bool,
554) -> Vec<(Utf8PathBuf, &'static [u8])> {
555    let mut planned = Vec::new();
556    for root in [CLAUDE_ROOT, AGENTS_ROOT] {
557        let root = home.join(root);
558        if !root.is_dir() {
559            continue;
560        }
561        for package in packages {
562            for file in package.files.iter().filter(|file| file.shared == shared) {
563                planned.push((root.join(&package.name).join(&file.path), file.bytes));
564            }
565        }
566    }
567    planned
568}
569
570/// What sits at each destination the embedded skills name.
571struct Installed {
572    /// Destinations the embedded skills name that hold no readable file.
573    missing: Vec<Utf8PathBuf>,
574    /// Destinations holding bytes that are not this binary's.
575    differing: Vec<Utf8PathBuf>,
576    /// How many destinations hold exactly this binary's bytes.
577    matching: usize,
578    /// Whether the record vouches for every differing destination, which
579    /// makes the difference a stale install rather than the operator's own
580    /// edit — and decides whether the fix needs `--force`.
581    all_recorded: bool,
582}
583
584/// Judge each destination the embedded skills name against what sits on disk.
585fn judge(planned: Vec<(Utf8PathBuf, &'static [u8])>, record: &Record) -> Installed {
586    let mut found = Installed {
587        missing: Vec::new(),
588        differing: Vec::new(),
589        matching: 0,
590        all_recorded: true,
591    };
592    for (destination, bytes) in planned {
593        match std::fs::read(&destination) {
594            Ok(held) if held == bytes => found.matching += 1,
595            Ok(held) => {
596                if !record.wrote(&destination, &Digest::of(&held)) {
597                    found.all_recorded = false;
598                }
599                found.differing.push(destination);
600            }
601            Err(_) => found.missing.push(destination),
602        }
603    }
604    found
605}
606
607/// The install that corrects a difference. Bytes the record vouches for are
608/// an older release's and go without asking; bytes it cannot account for are
609/// the operator's own, and overwriting those is what `--force` is.
610const fn reinstall(all_recorded: bool) -> &'static str {
611    if all_recorded {
612        "rk skill install --apply"
613    } else {
614        "rk skill install --apply --force"
615    }
616}
617
618/// The nearest ancestor of `path`, itself included, that exists as a
619/// directory.
620fn nearest_existing(path: &Utf8Path) -> Option<Utf8PathBuf> {
621    let mut current = Some(path);
622    while let Some(dir) = current {
623        if dir.is_dir() {
624            return Some(dir.to_owned());
625        }
626        current = dir.parent();
627    }
628    None
629}
630
631/// A directory accepts a write, leaving nothing behind.
632fn accepts_a_write(dir: &Utf8Path) -> std::io::Result<()> {
633    let probe = dir.join(format!(".rk-probe-{}", std::process::id()));
634    let written = std::fs::write(&probe, b"probe");
635    let _ = std::fs::remove_file(&probe);
636    written
637}
638
639/// The working directory's `origin` remote parses to a host, which is
640/// what forge and slug detection read.
641fn git_remote() -> ProbeResult {
642    let id = "git-remote";
643    let out = Command::new(git_bin())
644        .args(["remote", "get-url", "origin"])
645        .output();
646    let url = match out {
647        Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
648        _ => {
649            return ProbeResult::failed(
650                id,
651                ProbeClass::Soft,
652                "the working directory has no origin remote",
653                "pass --repo <owner/name> where a command needs the slug",
654            );
655        }
656    };
657    // The raw remote never reaches the message: a malformed URL can carry
658    // userinfo — `https://user:token@…` — and a probe result lands in
659    // captured output and CI logs, where a credential must never appear.
660    remote_host(&url).map_or_else(
661        || {
662            ProbeResult::failed(
663                id,
664                ProbeClass::Soft,
665                "the origin remote does not parse to a host",
666                "pass --repo <owner/name> where a command needs the slug",
667            )
668        },
669        |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
670    )
671}
672
673/// The host in a git remote URL, for the `scp`-like and URL forms.
674fn remote_host(url: &str) -> Option<String> {
675    if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
676        let authority = rest.split('/').next()?;
677        let host = authority
678            .rsplit_once('@')
679            .map_or(authority, |(_, host)| host);
680        let host = host.split(':').next()?;
681        return (!host.is_empty()).then(|| host.to_owned());
682    }
683    let (authority, path) = url.split_once(':')?;
684    let host = authority
685        .rsplit_once('@')
686        .map_or(authority, |(_, host)| host);
687    (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
688}
689
690/// A forge CLI is present and authenticated. `env_override` names the
691/// variable that substitutes the binary, which is also what keeps tests
692/// hermetic. `attempts` is tried in order and the first success wins, so
693/// a probe can prefer a sharper flag and still work where the CLI
694/// predates it.
695fn forge_cli(
696    id: &'static str,
697    env_override: &str,
698    default_bin: &str,
699    label: &str,
700    login: &str,
701    attempts: &[&[&str]],
702) -> ProbeResult {
703    let bin = std::env::var(env_override).unwrap_or_else(|_| default_bin.to_owned());
704    let mut spawned = false;
705    for args in attempts {
706        match Command::new(&bin).args(*args).output() {
707            Ok(out) if out.status.success() => {
708                return ProbeResult::ok(
709                    id,
710                    ProbeClass::Soft,
711                    format!("{default_bin} is authenticated"),
712                );
713            }
714            Ok(_) => spawned = true,
715            Err(_) => {}
716        }
717    }
718    if spawned {
719        ProbeResult::failed(
720            id,
721            ProbeClass::Soft,
722            format!("{default_bin} is not authenticated"),
723            format!("run {login}"),
724        )
725    } else {
726        ProbeResult::failed(
727            id,
728            ProbeClass::Soft,
729            format!("{default_bin} is not on PATH"),
730            format!("install {label}"),
731        )
732    }
733}
734
735/// One owner for a forge CLI's binary name, honoring the override that
736/// keeps the tests hermetic.
737#[must_use]
738pub fn forge_bin(forge: Forge) -> String {
739    std::env::var(forge.cli_override()).unwrap_or_else(|_| forge.cli().to_owned())
740}
741
742/// The version probe's stable name.
743const fn version_probe_id(forge: Forge) -> &'static str {
744    match forge {
745        Forge::Github => "gh-version",
746        Forge::Gitlab => "glab-version",
747    }
748}
749
750/// The first `<major>.<minor>.<patch>` run in a version line.
751///
752/// `gh version 2.19.0 (2022-10-25)` and `glab 1.114.0 (4d7c6cd)` both
753/// resolve. Nothing else in the crate parses a version string.
754#[must_use]
755pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
756    let bytes = text.as_bytes();
757    let mut start = 0;
758    while start < bytes.len() {
759        if !bytes[start].is_ascii_digit() {
760            start += 1;
761            continue;
762        }
763        let mut end = start;
764        while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
765            end += 1;
766        }
767        let run = &text[start..end];
768        let mut parts = run.split('.');
769        let parsed = (|| {
770            let major = parts.next()?.parse().ok()?;
771            let minor = parts.next()?.parse().ok()?;
772            let patch = parts.next()?.parse().ok()?;
773            Some((major, minor, patch))
774        })();
775        if let Some(version) = parsed {
776            return Some(version);
777        }
778        start = end.max(start + 1);
779    }
780    None
781}
782
783/// Run `<bin> --version` and parse it. A spawn failure, a non-zero exit,
784/// or output carrying no version run all answer `None`, because none of
785/// them proves a version this binary can trust.
786#[must_use]
787pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
788    let out = Command::new(bin).arg("--version").output().ok()?;
789    if !out.status.success() {
790        return None;
791    }
792    parse_cli_version(&String::from_utf8_lossy(&out.stdout))
793}
794
795/// A forge CLI is present and at or above the floor this binary calls.
796///
797/// Soft: a host that never starts work from an issue does not need it.
798fn forge_cli_floor(forge: Forge) -> ProbeResult {
799    let id = version_probe_id(forge);
800    let bin = forge_bin(forge);
801    let name = forge.cli();
802    let floor = forge.cli_floor();
803    match forge_cli_version(&bin) {
804        Some(found) if found >= floor => ProbeResult::ok(
805            id,
806            ProbeClass::Soft,
807            format!("{name} {} is at or above {}", show(found), show(floor)),
808        ),
809        Some(found) => ProbeResult::failed(
810            id,
811            ProbeClass::Soft,
812            format!(
813                "{name} {} is below the {} rk calls",
814                show(found),
815                show(floor)
816            ),
817            forge.cli_upgrade(),
818        ),
819        None => ProbeResult::failed(
820            id,
821            ProbeClass::Soft,
822            format!("{name} does not answer --version with a version"),
823            format!("install {name}"),
824        ),
825    }
826}
827
828/// `<major>.<minor>.<patch>` for a message.
829fn show((major, minor, patch): (u32, u32, u32)) -> String {
830    format!("{major}.{minor}.{patch}")
831}
832
833/// The gate a verb runs before its first forge call: the CLI is present
834/// and at or above [`Forge::cli_floor`]. Returns the binary to spawn and
835/// the version found.
836///
837/// It runs before anything is written, locally or remotely, so a stale CLI
838/// costs one local process and leaves the clone untouched.
839///
840/// # Errors
841///
842/// [`Reason::PrerequisiteUnmet`] where the CLI is absent, does not answer,
843/// or is below the floor.
844pub fn require_forge_cli(forge: Forge) -> Result<(String, (u32, u32, u32)), RkError> {
845    let bin = forge_bin(forge);
846    let name = forge.cli();
847    let floor = forge.cli_floor();
848    let Some(found) = forge_cli_version(&bin) else {
849        return Err(RkError::refusal(
850            Diagnostic::new(
851                Reason::PrerequisiteUnmet,
852                format!("{name} does not answer --version with a version"),
853            )
854            .expected(format!("{name} at or above {} on PATH", show(floor)))
855            .action(format!("install {name}, then rerun"))
856            .target_state("unchanged"),
857        ));
858    };
859    if found < floor {
860        return Err(RkError::refusal(
861            Diagnostic::new(
862                Reason::PrerequisiteUnmet,
863                format!(
864                    "{name} {} is below the {} rk calls",
865                    show(found),
866                    show(floor)
867                ),
868            )
869            .expected(format!("{name} at or above {}", show(floor)))
870            .action(forge.cli_upgrade())
871            .target_state("unchanged"),
872        ));
873    }
874    Ok((bin, found))
875}
876
877#[cfg(test)]
878mod tests {
879    use super::{parse_cli_version, remote_host};
880
881    /// Both forge CLIs print their version in a different shape, and a
882    /// line carrying no version resolves to nothing rather than to a
883    /// guess.
884    #[test]
885    fn a_version_line_parses_from_both_forge_clis() {
886        assert_eq!(
887            parse_cli_version("gh version 2.19.0 (2022-10-25)"),
888            Some((2, 19, 0))
889        );
890        assert_eq!(
891            parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
892            Some((1, 114, 0))
893        );
894        assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
895        assert_eq!(parse_cli_version("no version here"), None);
896        assert_eq!(parse_cli_version("gh version 2.19"), None);
897    }
898
899    /// The floor comparison orders by component, so no string comparison
900    /// survives it.
901    #[test]
902    fn a_floor_comparison_orders_by_component() {
903        assert!((2, 100, 0) > (2, 99, 0));
904        assert!((2, 9, 0) < (2, 19, 0));
905        assert!((2, 19, 0) >= (2, 19, 0));
906    }
907
908    #[test]
909    fn a_remote_host_parses_from_both_url_forms() {
910        assert_eq!(
911            remote_host("https://github.com/owner/name.git").as_deref(),
912            Some("github.com")
913        );
914        assert_eq!(
915            remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
916            Some("gitlab.com")
917        );
918        assert_eq!(
919            remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
920            Some("github.com")
921        );
922        assert_eq!(remote_host("not a url"), None);
923    }
924}