1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum ProbeClass {
23 Hard,
25 Soft,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum ProbeStatus {
33 Ok,
35 Failed,
37}
38
39#[derive(Debug, Serialize)]
41pub struct ProbeResult {
42 pub id: &'static str,
44 pub class: ProbeClass,
46 pub status: ProbeStatus,
48 pub message: String,
50 #[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
82pub const SKILL_PROBES: [&str; 3] = ["skill-roots", "skill-gate", "skill-sources"];
89
90pub const HARD_RUNTIME_TOOLS: [(&str, &str); 2] = [("git", "git"), ("sh", "bash")];
98
99#[must_use]
105pub fn git_bin() -> std::ffi::OsString {
106 crate::requirements::Tool::Git.program()
107}
108
109#[must_use]
112pub fn nix_bin() -> std::ffi::OsString {
113 crate::requirements::Tool::Nix.program()
114}
115
116#[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#[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#[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#[must_use]
157pub fn sh_bin() -> std::ffi::OsString {
158 crate::requirements::Tool::Sh.program()
159}
160
161#[must_use]
167pub fn git_cliff_bin() -> std::ffi::OsString {
168 crate::requirements::Tool::GitCliff.program()
169}
170
171#[must_use]
176pub fn uv_bin() -> std::ffi::OsString {
177 crate::requirements::Tool::Uv.program()
178}
179
180#[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 &[&["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
235fn 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
324fn 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
357fn 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
383fn 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
403fn 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
419fn 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
446fn 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
492fn 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
545fn 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
606type SkillContext = (Utf8PathBuf, Vec<crate::skills::Package>, Record);
609
610fn 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
626fn 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
636fn 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
661struct Installed {
663 missing: Vec<Utf8PathBuf>,
665 differing: Vec<Utf8PathBuf>,
667 matching: usize,
669 all_recorded: bool,
673}
674
675fn 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
698const 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
709fn 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
722fn 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
730fn 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 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
764fn 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
781fn 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#[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
839const 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#[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#[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
892fn 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
925fn 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 #[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 #[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}