1use std::path::Path;
9use std::process::Command;
10
11use speccoder_core::{
12 CommandData, CommandResult, CommandStatus, DoctorCheck, DoctorLevel, DoctorReport,
13};
14
15use crate::git::Git;
16
17fn check(name: &str, level: DoctorLevel, detail: impl Into<String>) -> DoctorCheck {
18 DoctorCheck {
19 check: name.to_owned(),
20 level,
21 detail: detail.into(),
22 code: None,
23 fix: Vec::new(),
24 remedy: None,
25 tasks: Vec::new(),
26 }
27}
28
29fn failed(name: &str, code: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
30 DoctorCheck {
31 code: Some(code.to_owned()),
32 fix: fix.iter().map(|f| (*f).to_owned()).collect(),
33 ..check(name, DoctorLevel::Fail, detail)
34 }
35}
36
37fn warned(name: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
38 DoctorCheck {
39 fix: fix.iter().map(|f| (*f).to_owned()).collect(),
40 ..check(name, DoctorLevel::Warn, detail)
41 }
42}
43
44#[must_use]
48pub fn plan_checks(
49 adapter: &dyn crate::format::Adapter,
50 snapshot: &speccoder_core::snapshot::ProjectSnapshot,
51 root: &Path,
52) -> Vec<DoctorCheck> {
53 let feature = snapshot
54 .feature
55 .as_ref()
56 .map(|f| f.feature.name.clone())
57 .unwrap_or_default();
58 crate::plan_lint::lints(adapter, snapshot, root)
59 .into_iter()
60 .map(|l| DoctorCheck {
61 code: Some(l.code.to_owned()),
62 fix: l.fix.clone(),
63 tasks: l.tasks.clone(),
64 remedy: l
65 .acceptance_at
66 .as_ref()
67 .map(|(task, story)| speccoder_core::Remedy {
68 label: format!("Write {story}'s acceptance at {task}"),
69 effect: format!("{task}'s tick can be verified on {story}'s scenarios"),
70 command: format!("speccoder task acceptance-at {task} --story {story}"),
71 undo: Some(format!(
72 "speccoder task acceptance-at {task} --story {story} --revoke"
73 )),
74 history: false,
75 run: speccoder_core::actions::Runnable::AcceptanceAt {
76 feature: feature.clone(),
77 story: story.clone(),
78 task: task.clone(),
79 revoke: false,
80 },
81 }),
82 ..check("plan", DoctorLevel::Warn, l.detail)
83 })
84 .collect()
85}
86
87fn preset_check(root: &Path) -> DoctorCheck {
89 let state = crate::preset::state(root);
90 let offer = |update: bool| speccoder_core::Remedy {
91 label: if update {
92 "Update the speccoder preset".to_owned()
93 } else {
94 "Add the speccoder preset".to_owned()
95 },
96 effect: "future task lists plan for evidence; no spec file changes".to_owned(),
97 command: "speccoder preset add --yes".to_owned(),
98 undo: Some("specify preset remove speccoder".to_owned()),
99 history: false,
100 run: speccoder_core::actions::Runnable::PresetAdd { update },
101 };
102 match (&state.installed, state.active_for_tasks_template) {
103 (Some(v), true) if v == &state.bundled || v == "unknown" => check(
104 "preset",
105 DoctorLevel::Pass,
106 format!("speccoder preset {v} active for tasks-template"),
107 ),
108 (Some(v), active) => DoctorCheck {
109 remedy: Some(offer(true)),
110 fix: vec!["speccoder preset add --yes".to_owned()],
111 ..check(
112 "preset",
113 DoctorLevel::Info,
114 format!(
115 "speccoder preset {v} installed{} — this speccoder bundles {}",
116 if active {
117 ""
118 } else {
119 ", not active for tasks-template"
120 },
121 state.bundled
122 ),
123 )
124 },
125 (None, _) => DoctorCheck {
126 remedy: Some(offer(false)),
127 fix: vec!["speccoder preset add --yes".to_owned()],
128 ..check(
129 "preset",
130 DoctorLevel::Info,
131 "speccoder preset not installed (tasks-template from Spec Kit's core)",
132 )
133 },
134 }
135}
136
137fn version(program: &str) -> Option<String> {
139 let out = Command::new(program).arg("--version").output().ok()?;
140 out.status.success().then(|| {
141 String::from_utf8_lossy(&out.stdout)
142 .lines()
143 .next()
144 .unwrap_or_default()
145 .trim()
146 .to_owned()
147 })
148}
149
150fn bedrock_key(
155 m: &crate::substrate::model_config::ModelConfig,
156 env: &dyn Fn(&str) -> Option<String>,
157) -> Vec<DoctorCheck> {
158 let set = |k: &str| env(k).filter(|v| !v.is_empty());
159 let home = std::path::PathBuf::from(set("HOME").unwrap_or_default());
160 let region = m.region.clone().unwrap_or_default();
161 let mut out = vec![match crate::aws::detect(&set, &home) {
162 Some(source) => {
163 let mut detail = format!("bedrock {} · {region} · {}", m.model, source.describe());
164 if source.kind == crate::aws::Kind::ApiKey
165 && let Some(p) = crate::aws::profile(&set, &home)
166 {
167 detail.push_str(&format!(
168 " (the API key is used; profile {} is not)",
169 p.name
170 ));
171 }
172 check("model key", DoctorLevel::Pass, detail)
173 }
174 None => DoctorCheck {
175 code: Some("KEY-MISSING".to_owned()),
176 fix: crate::substrate::model_config::bedrock_fixes(),
177 ..check(
178 "model key",
179 DoctorLevel::Fail,
180 format!(
181 "{} is not set and no AWS profile was found",
182 crate::aws::API_KEY
183 ),
184 )
185 },
186 }];
187 if let Some(other) = set("AWS_REGION").filter(|r| r.trim() != region) {
188 out.push(check(
189 "model region",
190 DoctorLevel::Warn,
191 format!("{region} in speccoder.toml, AWS_REGION is {other} — the file is used"),
192 ));
193 }
194 out
195}
196
197#[must_use]
199pub fn checks(
200 path: &Path,
201 feature: Option<&str>,
202 env: &dyn Fn(&str) -> Option<String>,
203) -> Vec<DoctorCheck> {
204 let mut out = Vec::new();
205 let git = version("git");
207 out.push(match &git {
208 Some(v) => check("git", DoctorLevel::Pass, v.clone()),
209 None => failed(
210 "git",
211 "GIT",
212 "git is not installed or does not run",
213 &["→ install git"],
214 ),
215 });
216 let top = Git::at(path).toplevel().ok().flatten();
217 let has_commit = top.as_ref().is_some_and(|t| {
218 Git::at(t)
219 .line(&["rev-parse", "--verify", "-q", "HEAD"])
220 .is_ok()
221 });
222 out.push(match (&top, has_commit) {
223 (Some(t), true) => check("repository", DoctorLevel::Pass, t.display().to_string()),
224 (Some(_), false) => failed(
225 "repository",
226 "NO-REPOSITORY",
227 "the repository has no commit yet",
228 &["git add -A && git commit -m init"],
229 ),
230 (None, _) => failed(
231 "repository",
232 "NO-REPOSITORY",
233 "not a git repository",
234 &["git init && git add -A && git commit -m init"],
235 ),
236 });
237 let opened = crate::format::open(path, feature);
239 let root = match &opened {
240 Ok(o) => {
241 let kind = o.adapter.wording().name;
242 let feature = o
243 .snapshot
244 .feature
245 .as_ref()
246 .map(|f| format!(", feature {}", f.feature.name))
247 .unwrap_or_default();
248 out.push(check(
249 "project",
250 DoctorLevel::Pass,
251 format!("{kind} at {}{feature}", o.snapshot.root),
252 ));
253 out.push(current_check(o.adapter, &o.snapshot));
254 let name = o.snapshot.feature.as_ref().map(|f| f.feature.name.as_str());
255 if let Some(c) = untracked_check(Path::new(&o.snapshot.root), name) {
256 out.push(c);
257 }
258 if let Some((tool, install)) = o.adapter.tool() {
259 out.push(match version(tool) {
260 Some(v) => check("spec tool", DoctorLevel::Pass, v),
261 None => DoctorCheck {
262 fix: vec![format!("→ {install}")],
263 ..check(
264 "spec tool",
265 DoctorLevel::Warn,
266 format!(
267 "`{tool}` is not on PATH: planning skills and cross-checks need it"
268 ),
269 )
270 },
271 });
272 }
273 if o.adapter.kind() == speccoder_core::spec::FormatKind::Speckit
275 && version("specify").is_some()
276 {
277 out.push(preset_check(Path::new(&o.snapshot.root)));
278 }
279 out.extend(o.adapter.doctor_checks(Path::new(&o.snapshot.root), env));
280 if o.snapshot.feature.is_some() {
282 out.extend(plan_checks(
283 o.adapter,
284 &o.snapshot,
285 Path::new(&o.snapshot.root),
286 ));
287 }
288 let root = std::path::PathBuf::from(&o.snapshot.root);
290 let layout = crate::layout::detect(&root, &crate::verify::check_argvs(&root));
291 if let Some(r) = layout.primary().filter(|r| r.story_dir.is_none()) {
292 out.push(warned(
293 "tests",
294 format!(
295 "no place the runner collects new test files, so no acceptance test is written: {}",
296 r.unknown.join("; ")
297 ),
298 &["→ let the runner collect a tests directory (Cargo: drop `autotests = false`; pytest: a `testpaths` entry that exists)"],
299 ));
300 }
301 root
302 }
303 Err(r) => {
304 let fix = speccoder_core::codes::lookup("NO-PROJECT")
305 .map(|e| speccoder_core::codes::fixes(e, "doctor", None, None))
306 .unwrap_or_default();
307 out.push(DoctorCheck {
308 code: Some("NO-PROJECT".to_owned()),
309 fix,
310 ..check("project", DoctorLevel::Fail, r.message.clone())
311 });
312 path.to_path_buf()
313 }
314 };
315 let config_dir = crate::verify::find_config(&root);
317 let text = config_dir
318 .as_ref()
319 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok());
320 let mut run_config = None;
321 let mut model = None;
322 match (&config_dir, &text) {
323 (Some(dir), Some(text)) => {
324 let parsed = crate::verify::config::parse(text)
325 .map(|_| ())
326 .and_then(|()| crate::substrate::model_config::parse(text).map(|m| model = m))
327 .and_then(|()| {
328 crate::substrate::run_config::parse(text).map(|c| run_config = Some(c))
329 });
330 match parsed {
331 Err(e) => out.push(failed(
332 "config",
333 "CONFIG-INVALID",
334 e,
335 &["→ fix the line the message names in speccoder.toml"],
336 )),
337 Ok(()) => {
338 let file = dir.join(crate::verify::config::FILE);
339 let g = Git::at(dir);
340 let tracked = g
341 .succeeds(&[
342 "ls-files",
343 "--error-unmatch",
344 "--",
345 crate::verify::config::FILE,
346 ])
347 .unwrap_or(false);
348 let clean = g
349 .succeeds(&["diff", "--quiet", "HEAD", "--", crate::verify::config::FILE])
350 .unwrap_or(false);
351 out.push(if tracked && clean {
352 check("config", DoctorLevel::Pass, file.display().to_string())
353 } else {
354 failed(
355 "config",
356 "CONFIG-NOT-TRACKED",
357 format!("{} is not committed as it is: speccoder reads committed files only", file.display()),
358 &["git add speccoder.toml && git commit -m \"speccoder: config\""],
359 )
360 });
361 }
362 }
363 }
364 _ => out.push(failed(
365 "config",
366 "CONFIG-MISSING",
367 "no speccoder.toml",
368 &["speccoder init"],
369 )),
370 }
371 if let Some(m) = &model {
373 out.push(check(
374 "model",
375 DoctorLevel::Pass,
376 format!("{}/{}", m.provider, m.model),
377 ));
378 let variable = m.key_variable().to_owned();
379 if m.provider == "bedrock" {
380 out.extend(bedrock_key(m, env));
381 } else {
382 out.push(
383 if variable.is_empty() || env(&variable).is_some_and(|v| !v.is_empty()) {
384 check(
385 "model key",
386 DoctorLevel::Pass,
387 format!("{} {}", m.provider, m.model),
388 )
389 } else {
390 DoctorCheck {
391 code: Some("KEY-MISSING".to_owned()),
392 fix: vec![format!("export {variable}=…")],
393 ..check(
394 "model key",
395 DoctorLevel::Fail,
396 format!("{variable} is not set"),
397 )
398 }
399 },
400 );
401 }
402 }
403 let sandbox = text
405 .as_deref()
406 .and_then(|t| crate::sandbox::parse(t).ok())
407 .unwrap_or_default();
408 out.push(match (crate::sandbox::backend(), sandbox.mode) {
409 (Ok(b), _) => check(
410 "sandbox",
411 DoctorLevel::Pass,
412 format!("{b:?}").to_lowercase(),
413 ),
414 (Err(e), crate::sandbox::Mode::Required) => failed(
415 "sandbox",
416 "SANDBOX-UNAVAILABLE",
417 e,
418 &["→ run where the OS sandbox exists, or set [sandbox] mode = \"auto\""],
419 ),
420 (Err(e), crate::sandbox::Mode::Off) => {
421 check("sandbox", DoctorLevel::Warn, format!("off ({e})"))
422 }
423 (Err(e), crate::sandbox::Mode::Auto) => warned(
424 "sandbox",
425 format!("not available, commands run unsandboxed: {e}"),
426 &["→ prefer a container or VM without your credentials"],
427 ),
428 });
429 let worktree = text
432 .as_deref()
433 .and_then(|t| crate::prepare::parse(t).ok())
434 .unwrap_or_default();
435 if let Some(dir) = crate::verify::find_config(path)
436 && let Some(row) = include_check(&dir, &worktree.include)
437 {
438 out.push(row);
439 }
440 out.push(match crate::worktree::state_dir() {
442 Some(dir) => {
443 let existing = dir.ancestors().find(|a| a.exists());
448 let writable = existing
449 .filter(|a| a.is_dir())
450 .is_some_and(|a| tempfile::tempfile_in(a).is_ok());
451 if writable {
452 check("state", DoctorLevel::Pass, dir.display().to_string())
453 } else {
454 failed(
455 "state",
456 "NO-STATE-DIR",
457 format!("{} is not writable", dir.display()),
458 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
459 )
460 }
461 }
462 None => failed(
463 "state",
464 "NO-STATE-DIR",
465 "no state directory (SPECCODER_STATE_DIR, XDG_STATE_HOME and HOME are unset)",
466 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
467 ),
468 });
469 if let Some(dir) = crate::worktree::state_dir() {
472 let project = crate::worktree::project_state_dir(&dir, &root);
473 if let Err(e) = crate::approvals::Decisions::load(&project) {
474 out.push(failed(
475 "decisions",
476 "CONFIG-INVALID",
477 e,
478 &[&format!(
479 "rm {} (it predates this version; its decisions are not carried over)",
480 project.join(crate::approvals::FILE).display()
481 )],
482 ));
483 }
484 }
485 let attest = run_config
487 .as_ref()
488 .map(|c| c.attest.clone())
489 .unwrap_or_default();
490 if attest.enabled {
491 let repo = top.clone().unwrap_or_else(|| root.clone());
492 out.push(match crate::attest::sign::signer(&repo, &attest) {
493 Ok(s) => check(
494 "signing",
495 DoctorLevel::Pass,
496 format!("attestations signed with {}", s.kind()),
497 ),
498 Err(e) => {
499 let fix = signing_fix(
500 std::env::var_os("HOME")
501 .map(std::path::PathBuf::from)
502 .as_deref(),
503 );
504 DoctorCheck {
506 fix,
507 ..check(
508 "signing",
509 DoctorLevel::Info,
510 format!("attestations are not signed (optional): {e}"),
511 )
512 }
513 }
514 });
515 }
516 if let Ok(r) = crate::github::repo(&root, None) {
519 let ctx = crate::github::Ctx::from_env();
520 out.push(match ctx.token(&r.host) {
521 Some((_, source)) => check(
522 "github",
523 DoctorLevel::Pass,
524 format!("{} on {} (token from {})", r.slug(), r.host, source.word()),
525 ),
526 None => DoctorCheck {
527 fix: vec!["speccoder auth login --with-token".to_owned()],
528 ..check(
529 "github",
530 DoctorLevel::Info,
531 format!(
532 "not signed in to {} (optional: for issues and pull requests)",
533 r.host
534 ),
535 )
536 },
537 });
538 }
539 let names = run_config
541 .as_ref()
542 .map(|c| c.instructions.clone())
543 .unwrap_or_else(|| vec!["AGENTS.md".to_owned()]);
544 let repo = top.unwrap_or_else(|| root.clone());
545 let given = crate::instructions::collect(&repo, &root, &[], &names);
546 out.push(if !given.is_empty() {
547 check(
548 "instructions",
549 DoctorLevel::Pass,
550 given
551 .iter()
552 .map(|i| i.path.clone())
553 .collect::<Vec<_>>()
554 .join(", "),
555 )
556 } else if repo.join("CLAUDE.md").is_file() && !names.iter().any(|n| n == "CLAUDE.md") {
557 warned(
558 "instructions",
559 "CLAUDE.md is here but not read: attempts get no repository instructions",
560 &["→ add [context] instructions = [\"AGENTS.md\", \"CLAUDE.md\"] to speccoder.toml"],
561 )
562 } else {
563 check("instructions", DoctorLevel::Pass, "none (no AGENTS.md)")
564 });
565 out
566}
567
568#[must_use]
571pub fn run(path: &Path, feature: Option<&str>, look: Option<&str>) -> CommandResult {
572 let mut checks = checks(path, feature, &|k| std::env::var(k).ok());
573 if let Some(look) = look {
574 checks.push(check("look", DoctorLevel::Pass, look));
575 }
576 let failures: Vec<&DoctorCheck> = checks
577 .iter()
578 .filter(|c| c.level == DoctorLevel::Fail)
579 .collect();
580 let warnings = checks
581 .iter()
582 .filter(|c| c.level == DoctorLevel::Warn)
583 .count();
584 let optional = checks
585 .iter()
586 .filter(|c| c.level == DoctorLevel::Info)
587 .count();
588 let report = DoctorReport {
589 speccoder: env!("CARGO_PKG_VERSION").to_owned(),
590 os: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
591 checks: checks.clone(),
592 };
593 let result = match failures.first() {
594 None => CommandResult::ok(
595 "doctor",
596 &format!(
597 "{} checks passed, {warnings} warning(s){}",
598 checks.len() - warnings - optional,
599 if optional > 0 {
600 format!(", {optional} optional")
601 } else {
602 String::new()
603 }
604 ),
605 ),
606 Some(first) => {
607 let entry = first
608 .code
609 .as_deref()
610 .and_then(speccoder_core::codes::lookup);
611 let status = entry.map_or(CommandStatus::Error, |e| e.status);
612 let message = format!(
613 "{} problem(s): {}",
614 failures.len(),
615 failures
616 .iter()
617 .map(|f| f.check.as_str())
618 .collect::<Vec<_>>()
619 .join(", ")
620 );
621 let mut r = CommandResult::new("doctor", status, &message).with_fix(first.fix.clone());
622 if let Some(code) = &first.code {
623 r = r.with_code(code);
624 }
625 r
626 }
627 };
628 result.with_data(CommandData::Doctor(Box::new(report)))
629}
630
631fn include_check(project: &Path, patterns: &[String]) -> Option<DoctorCheck> {
634 if patterns.is_empty() && !project.join(crate::include::FILE).is_file() {
635 return None;
636 }
637 let files = crate::include::matched(project, patterns);
638 let protected = crate::tools::paths::Protected::default();
639 let masked = files.iter().filter(|f| protected.is_secret(f)).count();
640 Some(if files.is_empty() {
641 warned(
642 "worktree include",
643 "nothing to copy: no git-ignored file matches [worktree] include or .worktreeinclude",
644 &[],
645 )
646 } else {
647 check(
648 "worktree include",
649 DoctorLevel::Pass,
650 format!(
651 "copied into each run's worktree: {}{}",
652 files.join(", "),
653 if masked == 0 {
654 String::new()
655 } else {
656 format!(
657 " ({masked} secret file(s): never committed, secret-named values masked)"
658 )
659 }
660 ),
661 )
662 })
663}
664
665fn untracked_check(root: &Path, feature: Option<&str>) -> Option<DoctorCheck> {
669 let out = Git::at(root)
670 .run(&["status", "--porcelain", "--untracked-files=normal", "."])
671 .ok()?;
672 const OUTPUT: &[&str] = &[
674 ".DS_Store",
675 ".speccoder",
676 ".venv",
677 "venv",
678 "node_modules",
679 "target",
680 "__pycache__",
681 ".pytest_cache",
682 ".mypy_cache",
683 ".ruff_cache",
684 ".tox",
685 ".nox",
686 ".coverage",
687 "dist",
688 "build",
689 ".next",
690 ".turbo",
691 ".gradle",
692 ".idea",
693 ".vscode",
694 ];
695 let config = |p: &str| {
696 let name = p.trim_end_matches('/').rsplit('/').next().unwrap_or(p);
697 if OUTPUT.contains(&name) {
698 return false;
699 }
700 name.starts_with('.')
701 || [".toml", ".json", ".yaml", ".yml", ".cfg", ".ini"]
702 .iter()
703 .any(|e| name.ends_with(e))
704 };
705 let found: Vec<String> = String::from_utf8_lossy(&out)
706 .lines()
707 .filter_map(|l| l.strip_prefix("?? "))
708 .filter(|p| config(p))
709 .map(str::to_owned)
710 .collect();
711 if found.is_empty() {
712 return None;
713 }
714 let list = found.join(" ");
715 Some(DoctorCheck {
716 fix: vec![format!(
717 "git add {list} && git commit -m \"Track project config\" (if the checks need them)"
718 )],
719 remedy: crate::code::setup_remedy(root, feature),
721 ..check(
722 "untracked",
723 DoctorLevel::Warn,
724 format!(
725 "{list} — not committed, so a worktree does not have it; a check that needs it cannot run there"
726 ),
727 )
728 })
729}
730
731fn current_check(
734 format: &'static dyn crate::format::Adapter,
735 snap: &speccoder_core::snapshot::ProjectSnapshot,
736) -> DoctorCheck {
737 let unit = format.messages().unit;
738 let problem = snap
739 .diagnostics
740 .iter()
741 .find(|d| d.code == "select.ambiguous" || d.code == "select.not-found");
742 match (&snap.feature, problem) {
743 (Some(f), _) => check(
744 "feature",
745 DoctorLevel::Pass,
746 format!(
747 "{} ({})",
748 f.feature.name,
749 crate::current::reason_text(snap.selection.reason, format)
750 ),
751 ),
752 (None, Some(d)) => DoctorCheck {
753 fix: vec!["→ speccoder feature".to_owned()]
754 .into_iter()
755 .chain(d.hint.iter().map(|h| format!("→ {h}")))
756 .collect(),
757 ..check("feature", DoctorLevel::Warn, d.message.clone())
758 },
759 (None, None) if snap.features.is_empty() => check(
760 "feature",
761 DoctorLevel::Pass,
762 format!("no {unit}s yet — `speccoder spec specify \"…\"` creates one"),
763 ),
764 (None, None) => DoctorCheck {
765 fix: vec!["→ speccoder feature".to_owned()],
766 ..check("feature", DoctorLevel::Warn, format!("no current {unit}"))
767 },
768 }
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 #[test]
776 fn the_files_a_worktree_gets_copied_are_named_and_nothing_is_copied() {
777 let dir = tempfile::tempdir().unwrap();
778 let r = dir.path();
779 let git = |args: &[&str]| {
780 assert!(
781 Command::new("git")
782 .args([
783 "-c",
784 "user.name=t",
785 "-c",
786 "user.email=t@e",
787 "-c",
788 "commit.gpgsign=false"
789 ])
790 .args(args)
791 .current_dir(r)
792 .status()
793 .unwrap()
794 .success()
795 );
796 };
797 std::fs::write(r.join(".gitignore"), ".env\n*.local\n").unwrap();
798 std::fs::write(r.join("app.local"), "x").unwrap();
799 std::fs::write(r.join("tracked.local"), "x").unwrap();
800 git(&["init", "-q"]);
801 git(&["add", "-A"]);
802 git(&["add", "-f", "tracked.local"]);
803 git(&["commit", "-qm", "init"]);
804 std::fs::write(r.join(".env"), "TOKEN=abcdefgh123\n").unwrap();
805 assert!(include_check(r, &[]).is_none());
806 let row = include_check(r, &[".env".to_owned()]).unwrap();
807 assert_eq!(row.level, DoctorLevel::Pass);
808 assert_eq!(
809 row.detail,
810 "copied into each run's worktree: .env (1 secret file(s): never committed, secret-named values masked)"
811 );
812 assert!(!row.detail.contains("abcdefgh123"));
813 std::fs::write(r.join(".worktreeinclude"), "*.local\n").unwrap();
815 let row = include_check(r, &[]).unwrap();
816 assert_eq!(row.detail, "copied into each run's worktree: app.local");
817 let row = include_check(r, &["nothing-here".to_owned()]).unwrap();
818 assert_eq!(row.detail, "copied into each run's worktree: app.local");
819 }
820
821 #[test]
822 fn outside_any_repository_every_problem_is_named_with_its_fix() {
823 let dir = tempfile::tempdir().unwrap();
824 let checks = checks(dir.path(), None, &|_| None);
825 let failing: Vec<(&str, Option<&str>)> = checks
826 .iter()
827 .filter(|c| c.level == DoctorLevel::Fail)
828 .map(|c| (c.check.as_str(), c.code.as_deref()))
829 .collect();
830 assert!(
831 failing.contains(&("repository", Some("NO-REPOSITORY"))),
832 "{failing:?}"
833 );
834 assert!(
835 failing.contains(&("project", Some("NO-PROJECT"))),
836 "{failing:?}"
837 );
838 assert!(
839 failing.contains(&("config", Some("CONFIG-MISSING"))),
840 "{failing:?}"
841 );
842 for c in checks.iter().filter(|c| c.level != DoctorLevel::Pass) {
843 assert!(!c.fix.is_empty(), "{} has no fix", c.check);
844 }
845 }
846}
847
848fn signing_fix(home: Option<&Path>) -> Vec<String> {
851 let found = home.and_then(|h| {
852 [
853 "id_ed25519.pub",
854 "id_ecdsa.pub",
855 "id_ed25519_sk.pub",
856 "id_ecdsa_sk.pub",
857 "id_rsa.pub",
858 ]
859 .iter()
860 .find(|k| h.join(".ssh").join(k).is_file())
861 });
862 let mut fix = Vec::new();
863 let key = match found {
864 Some(k) => format!("~/.ssh/{k}"),
865 None => {
866 fix.push("ssh-keygen -t ed25519".to_owned());
867 "~/.ssh/id_ed25519.pub".to_owned()
868 }
869 };
870 fix.push("git config --global gpg.format ssh".to_owned());
871 fix.push(format!("git config --global user.signingKey {key}"));
872 fix
873}
874
875#[cfg(test)]
876mod signing_tests {
877 use super::*;
878
879 #[test]
880 fn the_signing_fix_names_a_key_that_exists() {
881 let home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
882 let none = signing_fix(Some(home.path()));
883 assert_eq!(none[0], "ssh-keygen -t ed25519");
884 std::fs::create_dir_all(home.path().join(".ssh")).unwrap_or_else(|e| panic!("{e}"));
885 std::fs::write(home.path().join(".ssh/id_rsa.pub"), "ssh-rsa AAAA")
886 .unwrap_or_else(|e| panic!("{e}"));
887 let rsa = signing_fix(Some(home.path()));
888 assert_eq!(
889 rsa.last().map(String::as_str),
890 Some("git config --global user.signingKey ~/.ssh/id_rsa.pub")
891 );
892 assert!(!rsa.iter().any(|f| f.starts_with("ssh-keygen")));
893 }
894}