1use std::process::Command;
11
12use serde::Serialize;
13
14use crate::detect::Forge;
15use crate::skills::record::{RECORD_PATH, Record};
16use crate::skills::{AGENTS_ROOT, CLAUDE_ROOT, Digest};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum ProbeClass {
22 Hard,
24 Soft,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum ProbeStatus {
32 Ok,
34 Failed,
36}
37
38#[derive(Debug, Serialize)]
40pub struct ProbeResult {
41 pub id: &'static str,
43 pub class: ProbeClass,
45 pub status: ProbeStatus,
47 pub message: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub remediation: Option<String>,
52}
53
54impl ProbeResult {
55 fn ok(id: &'static str, class: ProbeClass, message: impl Into<String>) -> Self {
56 Self {
57 id,
58 class,
59 status: ProbeStatus::Ok,
60 message: message.into(),
61 remediation: None,
62 }
63 }
64
65 fn failed(
66 id: &'static str,
67 class: ProbeClass,
68 message: impl Into<String>,
69 remediation: impl Into<String>,
70 ) -> Self {
71 Self {
72 id,
73 class,
74 status: ProbeStatus::Failed,
75 message: message.into(),
76 remediation: Some(remediation.into()),
77 }
78 }
79}
80
81pub const SKILL_PROBES: [&str; 1] = ["skill-shadow"];
88
89pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
97
98#[must_use]
104pub fn git_bin() -> std::ffi::OsString {
105 crate::requirements::Tool::Git.program()
106}
107
108#[must_use]
111pub fn nix_bin() -> std::ffi::OsString {
112 crate::requirements::Tool::Nix.program()
113}
114
115#[must_use]
119pub fn direnv_bin() -> std::ffi::OsString {
120 std::env::var_os("RK_DIRENV_BIN")
121 .filter(|value| !value.is_empty())
122 .unwrap_or_else(|| "direnv".into())
123}
124
125#[must_use]
129pub fn nix() -> ProbeResult {
130 tool(
131 "nix",
132 "RK_NIX_BIN",
133 "nix",
134 "Nix; rk self-depend sync updates and builds the pinned devshell with it",
135 &["--version"],
136 )
137}
138
139#[must_use]
141pub fn direnv() -> ProbeResult {
142 tool(
143 "direnv",
144 "RK_DIRENV_BIN",
145 "direnv",
146 "direnv; it loads the devshell on directory entry",
147 &["version"],
148 )
149}
150
151#[must_use]
156pub fn sh_bin() -> std::ffi::OsString {
157 crate::requirements::Tool::Sh.program()
158}
159
160#[must_use]
166pub fn git_cliff_bin() -> std::ffi::OsString {
167 crate::requirements::Tool::GitCliff.program()
168}
169
170#[must_use]
175pub fn uv_bin() -> std::ffi::OsString {
176 crate::requirements::Tool::Uv.program()
177}
178
179#[must_use]
181pub fn run_all() -> Vec<ProbeResult> {
182 let mut probes = vec![
183 shell(),
184 git(),
185 state_root(),
186 skill_shadow(),
187 git_remote(),
188 forge_cli(
189 "gh-auth",
190 "RK_GH_BIN",
191 "gh",
192 "the GitHub CLI",
193 "gh auth login",
194 &[&["auth", "status", "--active"]],
199 ),
200 forge_cli(
201 "glab-auth",
202 "RK_GLAB_BIN",
203 "glab",
204 "the GitLab CLI",
205 "glab auth login",
206 &[&["auth", "status"]],
207 ),
208 forge_cli_floor(Forge::Github),
209 forge_cli_floor(Forge::Gitlab),
210 registry(),
211 nix(),
212 direnv(),
213 tool(
214 "cosign",
215 "RK_COSIGN_BIN",
216 "cosign",
217 "cosign; the release verify step checks a GitLab provenance bundle with it",
218 &["version"],
219 ),
220 tool(
221 "pypi-attestations",
222 "RK_PYPI_ATTESTATIONS_BIN",
223 "pypi-attestations",
224 "pypi-attestations; the release verify step checks a PyPI distribution's attestations with it",
225 &["--help"],
226 ),
227 ];
228 probes.extend(hook_probes());
229 probes
230}
231
232fn hook_probes() -> Vec<ProbeResult> {
239 let Ok(cwd) = std::env::current_dir() else {
240 return Vec::new();
241 };
242 let Some(installed) = crate::hooks::installed(&cwd) else {
243 return Vec::new();
244 };
245 let names = |filter: &dyn Fn(crate::hooks::Writer) -> bool| -> Vec<&'static str> {
246 installed
247 .iter()
248 .filter(|hook| filter(hook.writer))
249 .map(|hook| hook.event)
250 .collect()
251 };
252 let mut probes = vec![if installed
253 .iter()
254 .any(|hook| hook.event == "pre-merge-commit")
255 {
256 ProbeResult::ok(
257 "pre-merge-commit-hook",
258 ProbeClass::Soft,
259 "a pre-merge-commit hook is installed, the gate rk integrate asks git to run",
260 )
261 } else {
262 ProbeResult::failed(
263 "pre-merge-commit-hook",
264 ProbeClass::Soft,
265 "no pre-merge-commit hook is installed, so rk integrate has no gate to run in this clone",
266 "install it with the project's hook manager, for example prek install --hook-type pre-merge-commit",
267 )
268 }];
269 for (id, writer) in [
270 ("prek", crate::hooks::Writer::Prek),
271 ("pre-commit", crate::hooks::Writer::PreCommit),
272 ] {
273 let hooks = names(&|found| found == writer);
274 if hooks.is_empty() {
275 continue;
276 }
277 let answers = Command::new(id)
278 .arg("--version")
279 .output()
280 .is_ok_and(|out| out.status.success());
281 let wrote = hooks.join(", ");
282 probes.push(if answers {
283 ProbeResult::ok(id, ProbeClass::Soft, format!("{id} runs; it wrote the {wrote} hooks"))
284 } else {
285 ProbeResult::failed(
286 id,
287 ProbeClass::Soft,
288 format!("{id} does not run, and the {wrote} hooks call it"),
289 format!("install {id} on the host, or reinstall the hooks with the project's hook manager"),
290 )
291 });
292 }
293 let configured = names(&|found| found == crate::hooks::Writer::Configured);
294 if !configured.is_empty() {
295 let mut events = configured.clone();
296 events.dedup();
297 probes.push(ProbeResult::ok(
298 "configured-hooks",
299 ProbeClass::Soft,
300 format!(
301 "git's configuration defines {} hooks for {}",
302 configured.len(),
303 events.join(", ")
304 ),
305 ));
306 }
307 let own = names(&|found| found == crate::hooks::Writer::Own);
308 if !own.is_empty() {
309 probes.push(ProbeResult::ok(
310 "own-hooks",
311 ProbeClass::Soft,
312 format!(
313 "the {} hooks are the project's own, written by no known hook manager",
314 own.join(", ")
315 ),
316 ));
317 }
318 probes
319}
320
321fn tool(
325 id: &'static str,
326 env_override: &str,
327 default_bin: &str,
328 label: &str,
329 args: &[&str],
330) -> ProbeResult {
331 let bin = std::env::var(env_override)
332 .ok()
333 .filter(|value| !value.is_empty())
334 .unwrap_or_else(|| default_bin.to_owned());
335 match Command::new(&bin).args(args).output() {
336 Ok(out) if out.status.success() => {
337 ProbeResult::ok(id, ProbeClass::Soft, format!("{default_bin} runs"))
338 }
339 Ok(_) => ProbeResult::failed(
340 id,
341 ProbeClass::Soft,
342 format!("{default_bin} does not answer {}", args.join(" ")),
343 format!("repair {label}"),
344 ),
345 Err(_) => ProbeResult::failed(
346 id,
347 ProbeClass::Soft,
348 format!("{default_bin} is not on PATH"),
349 format!("install {label}"),
350 ),
351 }
352}
353
354fn registry() -> ProbeResult {
359 let id = "registry";
360 let answered = crate::http::send(
361 &crate::http::Request::get(
362 "https://index.crates.io/config.json",
363 std::time::Duration::from_secs(5),
364 )
365 .following(),
366 )
367 .is_ok_and(|answer| answer.ok());
368 if answered {
369 ProbeResult::ok(id, ProbeClass::Soft, "the crates.io index answers")
370 } else {
371 ProbeResult::failed(
372 id,
373 ProbeClass::Soft,
374 "the crates.io index does not answer",
375 "reach the network before rk versions --check or rk self-depend sync; every offline verb runs without it",
376 )
377 }
378}
379
380fn shell() -> ProbeResult {
382 let id = "sh";
383 match Command::new(sh_bin()).args(["-c", "exit 0"]).status() {
384 Ok(status) if status.success() => ProbeResult::ok(id, ProbeClass::Hard, "sh runs"),
385 Ok(status) => ProbeResult::failed(
386 id,
387 ProbeClass::Hard,
388 format!("sh exited {status}"),
389 "repair the POSIX shell on PATH",
390 ),
391 Err(source) => ProbeResult::failed(
392 id,
393 ProbeClass::Hard,
394 format!("sh does not spawn: {source}"),
395 "install a POSIX shell on PATH",
396 ),
397 }
398}
399
400fn git() -> ProbeResult {
403 let id = "git";
404 match Command::new(git_bin()).arg("--version").output() {
405 Ok(out) if out.status.success() => ProbeResult::ok(id, ProbeClass::Hard, "git runs"),
406 Ok(_) => ProbeResult::failed(
407 id,
408 ProbeClass::Hard,
409 "git does not answer --version",
410 "repair the git on PATH, or point RK_GIT_BIN at a working one",
411 ),
412 Err(_) => ProbeResult::failed(id, ProbeClass::Hard, "git is not on PATH", "install git"),
413 }
414}
415
416fn state_root() -> ProbeResult {
419 let id = "state-root";
420 let Some(root) = crate::applog::state_root() else {
421 return ProbeResult::failed(
422 id,
423 ProbeClass::Hard,
424 "neither XDG_STATE_HOME nor HOME is set",
425 "export HOME, or XDG_STATE_HOME",
426 );
427 };
428 let display = root.display().to_string();
429 let probe = root.join(format!(".probe-{}", std::process::id()));
430 let written = std::fs::create_dir_all(&root).and_then(|()| std::fs::write(&probe, b"probe"));
431 let _ = std::fs::remove_file(&probe);
432 match written {
433 Ok(()) => ProbeResult::ok(id, ProbeClass::Hard, format!("{display} is writable")),
434 Err(source) => ProbeResult::failed(
435 id,
436 ProbeClass::Hard,
437 format!("{display} is not writable: {source}"),
438 format!("make {display} writable"),
439 ),
440 }
441}
442
443fn skill_shadow() -> ProbeResult {
454 let id = SKILL_PROBES[0];
455 let Ok(home) = crate::skills::home() else {
456 return ProbeResult::failed(
457 id,
458 ProbeClass::Soft,
459 "neither HOME nor USERPROFILE is set, so no user-scope skill root resolves",
460 "export HOME",
461 );
462 };
463 let Ok(skills) = crate::skills::all() else {
464 return ProbeResult::failed(
465 id,
466 ProbeClass::Soft,
467 "this binary's embedded skills do not read",
468 "reinstall rk; the sources it was built from are defective",
469 );
470 };
471 let record = Record::load(&home.join(RECORD_PATH));
472 let mut vouched = Vec::new();
473 let mut edited = Vec::new();
474 for root in [CLAUDE_ROOT, AGENTS_ROOT] {
475 for skill in &skills {
476 let manual = home.join(root).join(&skill.name).join("SKILL.md");
477 if std::fs::symlink_metadata(&manual).is_err() {
478 continue;
479 }
480 let ours = std::fs::read(&manual)
481 .is_ok_and(|bytes| record.wrote(&manual, &Digest::of(&bytes)));
482 if ours {
483 vouched.push(manual);
484 } else {
485 edited.push(manual);
486 }
487 }
488 }
489 if let Some(first) = edited.first() {
490 return ProbeResult::failed(
491 id,
492 ProbeClass::Soft,
493 format!(
494 "{} user-scope SKILL.md file(s) shadow the project's landed skills, and the uninstall kept them as your edits, the first at {first}",
495 edited.len()
496 ),
497 format!(
498 "read {first} and each other kept copy, keep what you need elsewhere, and remove them by hand"
499 ),
500 );
501 }
502 if let Some(first) = vouched.first() {
503 return ProbeResult::failed(
504 id,
505 ProbeClass::Soft,
506 format!(
507 "{} user-scope SKILL.md file(s) that an earlier release installed shadow the project's landed skills, the first at {first}",
508 vouched.len()
509 ),
510 "rk skill uninstall --apply",
511 );
512 }
513 ProbeResult::ok(
514 id,
515 ProbeClass::Soft,
516 format!("no user-scope copy of an rk skill under {home} shadows a project's landed one"),
517 )
518}
519
520fn git_remote() -> ProbeResult {
523 let id = "git-remote";
524 let out = Command::new(git_bin())
525 .args(["remote", "get-url", "origin"])
526 .output();
527 let url = match out {
528 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
529 _ => {
530 return ProbeResult::failed(
531 id,
532 ProbeClass::Soft,
533 "the working directory has no origin remote",
534 "pass --repo <owner/name> where a command needs the slug",
535 );
536 }
537 };
538 remote_host(&url).map_or_else(
542 || {
543 ProbeResult::failed(
544 id,
545 ProbeClass::Soft,
546 "the origin remote does not parse to a host",
547 "pass --repo <owner/name> where a command needs the slug",
548 )
549 },
550 |host| ProbeResult::ok(id, ProbeClass::Soft, format!("origin resolves to {host}")),
551 )
552}
553
554fn remote_host(url: &str) -> Option<String> {
556 if let Some(rest) = url.split_once("://").map(|(_, rest)| rest) {
557 let authority = rest.split('/').next()?;
558 let host = authority
559 .rsplit_once('@')
560 .map_or(authority, |(_, host)| host);
561 let host = host.split(':').next()?;
562 return (!host.is_empty()).then(|| host.to_owned());
563 }
564 let (authority, path) = url.split_once(':')?;
565 let host = authority
566 .rsplit_once('@')
567 .map_or(authority, |(_, host)| host);
568 (!host.is_empty() && !path.is_empty()).then(|| host.to_owned())
569}
570
571fn forge_cli(
577 id: &'static str,
578 env_override: &str,
579 default_bin: &str,
580 label: &str,
581 login: &str,
582 attempts: &[&[&str]],
583) -> ProbeResult {
584 let bin = std::env::var(env_override)
585 .ok()
586 .filter(|value| !value.is_empty())
587 .unwrap_or_else(|| default_bin.to_owned());
588 let mut spawned = false;
589 for args in attempts {
590 match Command::new(&bin).args(*args).output() {
591 Ok(out) if out.status.success() => {
592 return ProbeResult::ok(
593 id,
594 ProbeClass::Soft,
595 format!("{default_bin} is authenticated"),
596 );
597 }
598 Ok(_) => spawned = true,
599 Err(_) => {}
600 }
601 }
602 if spawned {
603 ProbeResult::failed(
604 id,
605 ProbeClass::Soft,
606 format!("{default_bin} is not authenticated"),
607 format!("run {login}"),
608 )
609 } else {
610 ProbeResult::failed(
611 id,
612 ProbeClass::Soft,
613 format!("{default_bin} is not on PATH"),
614 format!("install {label}"),
615 )
616 }
617}
618
619#[must_use]
622pub fn forge_bin(forge: Forge) -> String {
623 crate::requirements::Tool::ForgeCli(forge)
624 .program()
625 .to_string_lossy()
626 .into_owned()
627}
628
629const fn version_probe_id(forge: Forge) -> &'static str {
631 match forge {
632 Forge::Github => "gh-version",
633 Forge::Gitlab => "glab-version",
634 }
635}
636
637#[must_use]
642pub fn parse_cli_version(text: &str) -> Option<(u32, u32, u32)> {
643 let bytes = text.as_bytes();
644 let mut start = 0;
645 while start < bytes.len() {
646 if !bytes[start].is_ascii_digit() {
647 start += 1;
648 continue;
649 }
650 let mut end = start;
651 while end < bytes.len() && (bytes[end].is_ascii_digit() || bytes[end] == b'.') {
652 end += 1;
653 }
654 let run = &text[start..end];
655 let mut parts = run.split('.');
656 let parsed = (|| {
657 let major = parts.next()?.parse().ok()?;
658 let minor = parts.next()?.parse().ok()?;
659 let patch = parts.next()?.parse().ok()?;
660 Some((major, minor, patch))
661 })();
662 if let Some(version) = parsed {
663 return Some(version);
664 }
665 start = end.max(start + 1);
666 }
667 None
668}
669
670#[must_use]
674pub fn forge_cli_version(bin: &str) -> Option<(u32, u32, u32)> {
675 let out = Command::new(bin).arg("--version").output().ok()?;
676 if !out.status.success() {
677 return None;
678 }
679 parse_cli_version(&String::from_utf8_lossy(&out.stdout))
680}
681
682fn forge_cli_floor(forge: Forge) -> ProbeResult {
686 let id = version_probe_id(forge);
687 let bin = forge_bin(forge);
688 let name = forge.cli();
689 let floor = forge.cli_floor();
690 match forge_cli_version(&bin) {
691 Some(found) if found >= floor => ProbeResult::ok(
692 id,
693 ProbeClass::Soft,
694 format!("{name} {} is at or above {}", show(found), show(floor)),
695 ),
696 Some(found) => ProbeResult::failed(
697 id,
698 ProbeClass::Soft,
699 format!(
700 "{name} {} is below the {} rk calls",
701 show(found),
702 show(floor)
703 ),
704 forge.cli_upgrade(),
705 ),
706 None => ProbeResult::failed(
707 id,
708 ProbeClass::Soft,
709 format!("{name} does not answer --version with a version"),
710 format!("install {name}"),
711 ),
712 }
713}
714
715fn show((major, minor, patch): (u32, u32, u32)) -> String {
717 format!("{major}.{minor}.{patch}")
718}
719
720#[cfg(test)]
721mod tests {
722 use super::{parse_cli_version, remote_host};
723
724 #[test]
728 fn a_version_line_parses_from_both_forge_clis() {
729 assert_eq!(
730 parse_cli_version("gh version 2.19.0 (2022-10-25)"),
731 Some((2, 19, 0))
732 );
733 assert_eq!(
734 parse_cli_version("glab 1.114.0 (4d7c6cd)\n"),
735 Some((1, 114, 0))
736 );
737 assert_eq!(parse_cli_version("gh version 2.99.0"), Some((2, 99, 0)));
738 assert_eq!(parse_cli_version("no version here"), None);
739 assert_eq!(parse_cli_version("gh version 2.19"), None);
740 }
741
742 #[test]
745 fn a_floor_comparison_orders_by_component() {
746 assert!((2, 100, 0) > (2, 99, 0));
747 assert!((2, 9, 0) < (2, 19, 0));
748 assert!((2, 19, 0) >= (2, 19, 0));
749 }
750
751 #[test]
752 fn a_remote_host_parses_from_both_url_forms() {
753 assert_eq!(
754 remote_host("https://github.com/owner/name.git").as_deref(),
755 Some("github.com")
756 );
757 assert_eq!(
758 remote_host("git@gitlab.com:group/sub/name.git").as_deref(),
759 Some("gitlab.com")
760 );
761 assert_eq!(
762 remote_host("ssh://git@github.com:22/owner/name.git").as_deref(),
763 Some("github.com")
764 );
765 assert_eq!(remote_host("not a url"), None);
766 }
767}