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