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