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