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 commands: Vec::new(),
27 }
28}
29
30fn failed(name: &str, code: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
31 DoctorCheck {
32 code: Some(code.to_owned()),
33 fix: fix.iter().map(|f| (*f).to_owned()).collect(),
34 ..check(name, DoctorLevel::Fail, detail)
35 }
36}
37
38fn warned(name: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
39 DoctorCheck {
40 fix: fix.iter().map(|f| (*f).to_owned()).collect(),
41 ..check(name, DoctorLevel::Warn, detail)
42 }
43}
44
45fn hooks_check(path: &Path, state: &Path) -> Option<DoctorCheck> {
48 use speccoder_core::hooks::TrustState;
49 let hooks = match crate::hooks::load(path) {
50 Ok(h) => h,
51 Err(e) => {
52 return Some(failed(
53 "hooks",
54 "HOOK-MISCONFIGURED",
55 e,
56 &["→ fix the hook in speccoder.toml", "speccoder hooks"],
57 ));
58 }
59 };
60 if hooks.is_empty() {
61 return None;
62 }
63 let dir = crate::hooks::dir(state, path);
64 let entries: Vec<_> = hooks
65 .iter()
66 .map(|d| crate::hooks::report::entry(&crate::hooks::digest(d, path), path, &dir))
67 .collect();
68 if let Some(e) = entries.iter().find(|e| e.problem.is_some()) {
69 return Some(warned(
70 "hooks",
71 format!("{}: {}", e.name, e.problem.clone().unwrap_or_default()),
72 &["speccoder hooks"],
73 ));
74 }
75 let waiting: Vec<&str> = entries
77 .iter()
78 .filter(|e| e.state != TrustState::Trusted && !e.name.contains('.'))
79 .map(|e| e.name.as_str())
80 .collect();
81 if waiting.is_empty() {
82 let gates = entries.iter().filter(|e| e.role == "gate").count();
83 return Some(check(
84 "hooks",
85 DoctorLevel::Pass,
86 format!(
87 "{} trusted — {gates} gate(s), {} observer(s)",
88 entries.len(),
89 entries.len() - gates
90 ),
91 ));
92 }
93 Some(warned(
94 "hooks",
95 format!(
96 "{} wait(s) for your trust: a gate blocks every tick until then, an observer hears nothing",
97 waiting.join(", ")
98 ),
99 &["speccoder hooks trust"],
100 ))
101}
102
103fn extensions_check(path: &Path, state: &Path) -> Option<DoctorCheck> {
106 let all = crate::extensions::load(path);
107 if all.is_empty() {
108 return None;
109 }
110 let lines = crate::extensions::report::doctor_lines(path, state);
111 if let Some((_, l)) = lines.iter().find(|(_, l)| l.contains("cannot be loaded")) {
112 return Some(failed(
113 "extensions",
114 "EXTENSION-UNLOADABLE",
115 l.clone(),
116 &[
117 "→ fix what it names in the extension's folder, or remove it",
118 "speccoder extensions",
119 ],
120 ));
121 }
122 if lines.is_empty() {
123 return Some(check(
124 "extensions",
125 DoctorLevel::Pass,
126 format!("{} trusted", all.len()),
127 ));
128 }
129 Some(warned(
130 "extensions",
131 lines
132 .iter()
133 .map(|(_, l)| l.as_str())
134 .collect::<Vec<_>>()
135 .join("; "),
136 &["speccoder extensions"],
137 ))
138}
139
140#[must_use]
144pub fn plan_checks(
145 adapter: &dyn crate::format::Adapter,
146 snapshot: &speccoder_core::snapshot::ProjectSnapshot,
147 root: &Path,
148) -> Vec<DoctorCheck> {
149 let feature = snapshot
150 .feature
151 .as_ref()
152 .map(|f| f.feature.name.clone())
153 .unwrap_or_default();
154 crate::plan_lint::lints(adapter, snapshot, root)
155 .into_iter()
156 .map(|l| DoctorCheck {
157 code: Some(l.code.to_owned()),
158 fix: l.fix.clone(),
159 tasks: l.tasks.clone(),
160 remedy: l
161 .acceptance_at
162 .as_ref()
163 .map(|(task, story)| speccoder_core::Remedy {
164 label: format!("Write {story}'s acceptance at {task}"),
165 effect: format!("{task}'s tick can be verified on {story}'s scenarios"),
166 command: format!("speccoder task acceptance-at {task} --story {story}"),
167 undo: Some(format!(
168 "speccoder task acceptance-at {task} --story {story} --revoke"
169 )),
170 history: false,
171 run: speccoder_core::actions::Runnable::AcceptanceAt {
172 feature: feature.clone(),
173 story: story.clone(),
174 task: task.clone(),
175 revoke: false,
176 },
177 }),
178 ..check("plan", DoctorLevel::Warn, l.detail)
179 })
180 .collect()
181}
182
183fn preset_check(root: &Path) -> DoctorCheck {
185 let state = crate::preset::state(root);
186 let offer = |update: bool| speccoder_core::Remedy {
187 label: if update {
188 "Update the speccoder preset".to_owned()
189 } else {
190 "Add the speccoder preset".to_owned()
191 },
192 effect: "future task lists plan for evidence; no spec file changes".to_owned(),
193 command: "speccoder preset add --yes".to_owned(),
194 undo: Some("specify preset remove speccoder".to_owned()),
195 history: false,
196 run: speccoder_core::actions::Runnable::PresetAdd { update },
197 };
198 match (&state.installed, state.active_for_tasks_template) {
199 (Some(v), true) if v == &state.bundled || v == "unknown" => check(
200 "preset",
201 DoctorLevel::Pass,
202 format!("speccoder preset {v} active for tasks-template"),
203 ),
204 (Some(v), active) => DoctorCheck {
205 remedy: Some(offer(true)),
206 fix: vec!["speccoder preset add --yes".to_owned()],
207 ..check(
208 "preset",
209 DoctorLevel::Info,
210 format!(
211 "speccoder preset {v} installed{} — this speccoder bundles {}",
212 if active {
213 ""
214 } else {
215 ", not active for tasks-template"
216 },
217 state.bundled
218 ),
219 )
220 },
221 (None, _) => DoctorCheck {
222 remedy: Some(offer(false)),
223 fix: vec!["speccoder preset add --yes".to_owned()],
224 ..check(
225 "preset",
226 DoctorLevel::Info,
227 "speccoder preset not installed (tasks-template from Spec Kit's core)",
228 )
229 },
230 }
231}
232
233fn version(program: &str) -> Option<String> {
235 let out = Command::new(program).arg("--version").output().ok()?;
236 out.status.success().then(|| {
237 String::from_utf8_lossy(&out.stdout)
238 .lines()
239 .next()
240 .unwrap_or_default()
241 .trim()
242 .to_owned()
243 })
244}
245
246fn bedrock_key(
251 m: &crate::substrate::model_config::ModelConfig,
252 env: &dyn Fn(&str) -> Option<String>,
253) -> Vec<DoctorCheck> {
254 let set = |k: &str| env(k).filter(|v| !v.is_empty());
255 let home = std::path::PathBuf::from(set("HOME").unwrap_or_default());
256 let region = m.region.clone().unwrap_or_default();
257 let mut out = vec![match crate::aws::detect(&set, &home) {
258 Some(source) => {
259 let mut detail = format!("bedrock {} · {region} · {}", m.model, source.describe());
260 if source.kind == crate::aws::Kind::ApiKey
261 && let Some(p) = crate::aws::profile(&set, &home)
262 {
263 detail.push_str(&format!(
264 " (the API key is used; profile {} is not)",
265 p.name
266 ));
267 }
268 check("model key", DoctorLevel::Pass, detail)
269 }
270 None => DoctorCheck {
271 code: Some("KEY-MISSING".to_owned()),
272 fix: crate::substrate::model_config::bedrock_fixes(),
273 ..check(
274 "model key",
275 DoctorLevel::Fail,
276 format!(
277 "{} is not set and no AWS profile was found",
278 crate::aws::API_KEY
279 ),
280 )
281 },
282 }];
283 if let Some(other) = set("AWS_REGION").filter(|r| r.trim() != region) {
284 out.push(check(
285 "model region",
286 DoctorLevel::Warn,
287 format!("{region} in speccoder.toml, AWS_REGION is {other} — the file is used"),
288 ));
289 }
290 out
291}
292
293#[must_use]
295pub fn checks(
296 path: &Path,
297 feature: Option<&str>,
298 env: &dyn Fn(&str) -> Option<String>,
299) -> Vec<DoctorCheck> {
300 let mut out = Vec::new();
301 let git = version("git");
303 out.push(match &git {
304 Some(v) => check("git", DoctorLevel::Pass, v.clone()),
305 None => failed(
306 "git",
307 "GIT",
308 "git is not installed or does not run",
309 &["→ install git"],
310 ),
311 });
312 let top = Git::at(path).toplevel().ok().flatten();
313 let has_commit = top.as_ref().is_some_and(|t| {
314 Git::at(t)
315 .line(&["rev-parse", "--verify", "-q", "HEAD"])
316 .is_ok()
317 });
318 out.push(match (&top, has_commit) {
319 (Some(t), true) => check("repository", DoctorLevel::Pass, t.display().to_string()),
320 (Some(_), false) => failed(
321 "repository",
322 "NO-REPOSITORY",
323 "the repository has no commit yet",
324 &["git add -A && git commit -m init"],
325 ),
326 (None, _) => failed(
327 "repository",
328 "NO-REPOSITORY",
329 "not a git repository",
330 &["git init && git add -A && git commit -m init"],
331 ),
332 });
333 let opened = crate::format::open(path, feature);
335 let root = match &opened {
336 Ok(o) => {
337 let kind = o.adapter.wording().name;
338 let feature = o
339 .snapshot
340 .feature
341 .as_ref()
342 .map(|f| format!(", feature {}", f.feature.name))
343 .unwrap_or_default();
344 out.push(check(
345 "project",
346 DoctorLevel::Pass,
347 format!("{kind} at {}{feature}", o.snapshot.root),
348 ));
349 out.push(current_check(o.adapter, &o.snapshot));
350 let name = o.snapshot.feature.as_ref().map(|f| f.feature.name.as_str());
351 if let Some(c) = untracked_check(Path::new(&o.snapshot.root), name) {
352 out.push(c);
353 }
354 if let Some((tool, install)) = o.adapter.tool() {
355 out.push(match version(tool) {
356 Some(v) => check("spec tool", DoctorLevel::Pass, v),
357 None => DoctorCheck {
358 fix: vec![format!("→ {install}")],
359 ..check(
360 "spec tool",
361 DoctorLevel::Warn,
362 format!(
363 "`{tool}` is not on PATH: planning skills and cross-checks need it"
364 ),
365 )
366 },
367 });
368 }
369 if o.adapter.kind() == speccoder_core::spec::FormatKind::Speckit
371 && version("specify").is_some()
372 {
373 out.push(preset_check(Path::new(&o.snapshot.root)));
374 }
375 out.extend(o.adapter.doctor_checks(Path::new(&o.snapshot.root), env));
376 if let Some(f) = o.snapshot.feature.as_ref() {
378 out.extend(plan_checks(
379 o.adapter,
380 &o.snapshot,
381 Path::new(&o.snapshot.root),
382 ));
383 if let Some(l) =
385 crate::status::lanes(Path::new(&o.snapshot.root), Some(&f.feature.name))
386 {
387 out.push(DoctorCheck {
388 tasks: l.planning.iter().chain(&l.yours).cloned().collect(),
389 ..check("lanes", DoctorLevel::Pass, l.line())
390 });
391 }
392 }
393 let root = std::path::PathBuf::from(&o.snapshot.root);
395 let layout = crate::layout::detect(&root, &crate::verify::check_argvs(&root));
396 if let Some(r) = layout.primary().filter(|r| r.story_dir.is_none()) {
397 out.push(warned(
398 "tests",
399 format!(
400 "no place the runner collects new test files, so no acceptance test is written: {}",
401 r.unknown.join("; ")
402 ),
403 &["→ let the runner collect a tests directory (Cargo: drop `autotests = false`; pytest: a `testpaths` entry that exists)"],
404 ));
405 }
406 root
407 }
408 Err(r) => {
409 let fix = speccoder_core::codes::lookup("NO-PROJECT")
410 .map(|e| speccoder_core::codes::fixes(e, "doctor", None, None))
411 .unwrap_or_default();
412 out.push(DoctorCheck {
413 code: Some("NO-PROJECT".to_owned()),
414 fix,
415 ..check("project", DoctorLevel::Fail, r.message.clone())
416 });
417 path.to_path_buf()
418 }
419 };
420 let config_dir = crate::verify::find_config(&root);
422 let text = config_dir
423 .as_ref()
424 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok());
425 let mut run_config = None;
426 let mut model = None;
427 match (&config_dir, &text) {
428 (Some(dir), Some(text)) => {
429 let parsed = crate::verify::config::parse(text)
430 .map(|_| ())
431 .and_then(|()| crate::substrate::model_config::parse(text).map(|m| model = m))
432 .and_then(|()| {
433 crate::substrate::run_config::parse(text).map(|c| run_config = Some(c))
434 });
435 match parsed {
436 Err(e) => out.push(failed(
437 "config",
438 "CONFIG-INVALID",
439 e,
440 &["→ fix the line the message names in speccoder.toml"],
441 )),
442 Ok(()) => {
443 let file = dir.join(crate::verify::config::FILE);
444 let g = Git::at(dir);
445 let tracked = g
446 .succeeds(&[
447 "ls-files",
448 "--error-unmatch",
449 "--",
450 crate::verify::config::FILE,
451 ])
452 .unwrap_or(false);
453 let clean = g
454 .succeeds(&["diff", "--quiet", "HEAD", "--", crate::verify::config::FILE])
455 .unwrap_or(false);
456 out.push(if tracked && clean {
457 check("config", DoctorLevel::Pass, file.display().to_string())
458 } else {
459 failed(
460 "config",
461 "CONFIG-NOT-TRACKED",
462 format!("{} is not committed as it is: speccoder reads committed files only", file.display()),
463 &["git add speccoder.toml && git commit -m \"speccoder: config\""],
464 )
465 });
466 }
467 }
468 }
469 _ => out.push(failed(
470 "config",
471 "CONFIG-MISSING",
472 "no speccoder.toml",
473 &["speccoder init"],
474 )),
475 }
476 if let Some(m) = &model {
478 out.push(check(
479 "model",
480 DoctorLevel::Pass,
481 format!("{}/{}", m.provider, m.model),
482 ));
483 let variable = m.key_variable().to_owned();
484 if m.provider == "bedrock" {
485 out.extend(bedrock_key(m, env));
486 } else {
487 out.push(
488 if variable.is_empty() || env(&variable).is_some_and(|v| !v.is_empty()) {
489 check(
490 "model key",
491 DoctorLevel::Pass,
492 format!("{} {}", m.provider, m.model),
493 )
494 } else {
495 DoctorCheck {
496 code: Some("KEY-MISSING".to_owned()),
497 fix: vec![format!("export {variable}=…")],
498 ..check(
499 "model key",
500 DoctorLevel::Fail,
501 format!("{variable} is not set"),
502 )
503 }
504 },
505 );
506 }
507 }
508 let sandbox = text
510 .as_deref()
511 .and_then(|t| crate::sandbox::parse(t).ok())
512 .unwrap_or_default();
513 out.push(match (crate::sandbox::backend(), sandbox.mode) {
514 (Ok(b), _) => check(
515 "sandbox",
516 DoctorLevel::Pass,
517 format!("{} — commands write only the workspace", b.mechanism()),
518 ),
519 (Err(e), crate::sandbox::Mode::Required) => failed(
520 "sandbox",
521 "SANDBOX-UNAVAILABLE",
522 e,
523 &["→ run where the OS sandbox exists, or set [sandbox] mode = \"auto\""],
524 ),
525 (Err(e), crate::sandbox::Mode::Off) => {
526 check("sandbox", DoctorLevel::Warn, format!("off ({e})"))
527 }
528 (Err(e), crate::sandbox::Mode::Auto) => warned(
529 "sandbox",
530 format!("not available, commands run unsandboxed: {e}"),
531 &["→ prefer a container or VM without your credentials"],
532 ),
533 });
534 if crate::sandbox::backend().is_ok() && sandbox.mode != crate::sandbox::Mode::Off {
536 out.push(check(
537 "caches",
538 DoctorLevel::Pass,
539 "read-only to runs — prepare and commands with the network fill them; what tools derive stays in the run's private cache".to_owned(),
540 ));
541 }
542 let (fine, said) = crate::sandbox::contained_at(path);
544 out.push(check(
545 "contained",
546 if fine {
547 DoctorLevel::Pass
548 } else {
549 DoctorLevel::Warn
550 },
551 said,
552 ));
553 if let Some(row) = crate::worktree::state_dir().and_then(|s| hooks_check(path, &s)) {
555 out.push(row);
556 }
557 if let Some(row) = crate::worktree::state_dir().and_then(|s| extensions_check(path, &s)) {
558 out.push(row);
559 }
560 let worktree = text
563 .as_deref()
564 .and_then(|t| crate::prepare::parse(t).ok())
565 .unwrap_or_default();
566 if let Some(dir) = crate::verify::find_config(path)
567 && let Some(row) = include_check(&dir, &worktree.include)
568 {
569 out.push(row);
570 }
571 out.push(match crate::worktree::state_dir() {
573 Some(dir) => {
574 let existing = dir.ancestors().find(|a| a.exists());
579 let writable = existing
580 .filter(|a| a.is_dir())
581 .is_some_and(|a| tempfile::tempfile_in(a).is_ok());
582 if writable {
583 check("state", DoctorLevel::Pass, dir.display().to_string())
584 } else {
585 failed(
586 "state",
587 "NO-STATE-DIR",
588 format!("{} is not writable", dir.display()),
589 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
590 )
591 }
592 }
593 None => failed(
594 "state",
595 "NO-STATE-DIR",
596 "no state directory (SPECCODER_STATE_DIR, XDG_STATE_HOME and HOME are unset)",
597 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
598 ),
599 });
600 if let Some(dir) = crate::worktree::state_dir() {
603 let project = crate::worktree::project_state_dir(&dir, &root);
604 if let Err(e) = crate::approvals::Decisions::load(&project) {
605 out.push(failed(
606 "decisions",
607 "CONFIG-INVALID",
608 e,
609 &[&format!(
610 "rm {} (it predates this version; its decisions are not carried over)",
611 project.join(crate::approvals::FILE).display()
612 )],
613 ));
614 }
615 }
616 let attest = run_config
618 .as_ref()
619 .map(|c| c.attest.clone())
620 .unwrap_or_default();
621 if attest.enabled {
622 let repo = top.clone().unwrap_or_else(|| root.clone());
623 out.push(match crate::attest::sign::signer(&repo, &attest) {
624 Ok(s) => check(
625 "signing",
626 DoctorLevel::Pass,
627 format!("attestations signed with {}", s.kind()),
628 ),
629 Err(e) => {
630 let fix = signing_fix(
631 std::env::var_os("HOME")
632 .map(std::path::PathBuf::from)
633 .as_deref(),
634 );
635 DoctorCheck {
637 fix,
638 ..check(
639 "signing",
640 DoctorLevel::Info,
641 format!("attestations are not signed (optional): {e}"),
642 )
643 }
644 }
645 });
646 }
647 if let Ok(r) = crate::github::repo(&root, None) {
650 let ctx = crate::github::Ctx::from_env();
651 out.push(match ctx.token(&r.host) {
652 Some((_, source)) => check(
653 "github",
654 DoctorLevel::Pass,
655 format!("{} on {} (token from {})", r.slug(), r.host, source.word()),
656 ),
657 None => DoctorCheck {
658 fix: vec!["speccoder auth login --with-token".to_owned()],
659 ..check(
660 "github",
661 DoctorLevel::Info,
662 format!(
663 "not signed in to {} (optional: for issues and pull requests)",
664 r.host
665 ),
666 )
667 },
668 });
669 }
670 let names = run_config
672 .as_ref()
673 .map(|c| c.instructions.clone())
674 .unwrap_or_else(|| vec!["AGENTS.md".to_owned()]);
675 let repo = top.unwrap_or_else(|| root.clone());
676 let given = crate::instructions::collect(&repo, &root, &[], &names);
677 out.push(if !given.is_empty() {
678 check(
679 "instructions",
680 DoctorLevel::Pass,
681 given
682 .iter()
683 .map(|i| i.path.clone())
684 .collect::<Vec<_>>()
685 .join(", "),
686 )
687 } else if repo.join("CLAUDE.md").is_file() && !names.iter().any(|n| n == "CLAUDE.md") {
688 warned(
689 "instructions",
690 "CLAUDE.md is here but not read: attempts get no repository instructions",
691 &["→ add [context] instructions = [\"AGENTS.md\", \"CLAUDE.md\"] to speccoder.toml"],
692 )
693 } else {
694 check("instructions", DoctorLevel::Pass, "none (no AGENTS.md)")
695 });
696 out
697}
698
699#[must_use]
702pub fn run(path: &Path, feature: Option<&str>, look: Option<&str>) -> CommandResult {
703 report(path, feature, look, None)
704}
705
706#[must_use]
709pub fn agents(path: &Path, feature: Option<&str>, probe: bool) -> CommandResult {
710 report(path, feature, None, Some(probe))
711}
712
713fn configured_agent(path: &Path) -> Option<crate::acp::agent::Agent> {
715 let text = crate::verify::find_config(path)
716 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())?;
717 match crate::external::parse(&text).ok()??.kind {
718 crate::external::Kind::Acp(a) => Some(a),
719 crate::external::Kind::Command(_) => None,
720 }
721}
722
723fn executor_check(agent: &crate::acp::agent::Agent) -> DoctorCheck {
726 let on_path = |x: &str| crate::acp::agent::on_path(x);
727 let Some(p) = agent.preset() else {
728 let argv = agent.command(on_path);
729 let runs = argv.first().is_some_and(|a| on_path(a));
730 return if runs {
731 check(
732 "executor",
733 DoctorLevel::Pass,
734 format!(
735 "{} over ACP · not known whether it asks before it acts",
736 argv.join(" ")
737 ),
738 )
739 } else {
740 warned(
741 "executor",
742 format!(
743 "`{}` is not on PATH",
744 argv.first().map_or("", String::as_str)
745 ),
746 &[],
747 )
748 };
749 };
750 let c = crate::acp::ready::card(p, Some(agent), &on_path, false);
751 let word = |t: &speccoder_core::AgentTick| match t.state {
752 speccoder_core::TickState::Yes => "yes",
753 speccoder_core::TickState::No => "no",
754 speccoder_core::TickState::Unknown => "not known",
755 };
756 let detail = format!(
757 "{} · installed: {} ({}) · signed in: {} ({}) · asks first: {} ({})",
758 c.name,
759 word(&c.installed),
760 c.installed.detail,
761 word(&c.signed_in),
762 c.signed_in.detail,
763 word(&c.asks_first),
764 c.asks_first.detail
765 );
766 let bad = [&c.installed, &c.asks_first]
767 .into_iter()
768 .find(|t| t.state == speccoder_core::TickState::No);
769 match bad {
770 None => check("executor", DoctorLevel::Pass, detail),
771 Some(t) => DoctorCheck {
772 fix: t.fix.clone(),
773 commands: t.commands.clone(),
774 ..warned("executor", detail, &[])
775 },
776 }
777}
778
779fn report(
780 path: &Path,
781 feature: Option<&str>,
782 look: Option<&str>,
783 cards: Option<bool>,
784) -> CommandResult {
785 let mut checks = checks(path, feature, &|k| std::env::var(k).ok());
786 let chosen = configured_agent(path);
787 if let Some(a) = &chosen {
788 checks.push(executor_check(a));
789 }
790 if let Some(look) = look {
791 checks.push(check("look", DoctorLevel::Pass, look));
792 }
793 let failures: Vec<&DoctorCheck> = checks
794 .iter()
795 .filter(|c| c.level == DoctorLevel::Fail)
796 .collect();
797 let warnings = checks
798 .iter()
799 .filter(|c| c.level == DoctorLevel::Warn)
800 .count();
801 let optional = checks
802 .iter()
803 .filter(|c| c.level == DoctorLevel::Info)
804 .count();
805 let report = DoctorReport {
806 speccoder: env!("CARGO_PKG_VERSION").to_owned(),
807 os: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
808 checks: checks.clone(),
809 agents: cards.map_or_else(Vec::new, |probe| {
810 crate::acp::ready::cards(chosen.as_ref(), probe)
811 }),
812 };
813 let result = match failures.first() {
814 None => CommandResult::ok(
815 "doctor",
816 &format!(
817 "{} checks passed, {warnings} warning(s){}",
818 checks.len() - warnings - optional,
819 if optional > 0 {
820 format!(", {optional} optional")
821 } else {
822 String::new()
823 }
824 ),
825 ),
826 Some(first) => {
827 let entry = first
828 .code
829 .as_deref()
830 .and_then(speccoder_core::codes::lookup);
831 let status = entry.map_or(CommandStatus::Error, |e| e.status);
832 let message = format!(
833 "{} problem(s): {}",
834 failures.len(),
835 failures
836 .iter()
837 .map(|f| f.check.as_str())
838 .collect::<Vec<_>>()
839 .join(", ")
840 );
841 let mut r = CommandResult::new("doctor", status, &message).with_fix(first.fix.clone());
842 if let Some(code) = &first.code {
843 r = r.with_code(code);
844 }
845 r
846 }
847 };
848 result.with_data(CommandData::Doctor(Box::new(report)))
849}
850
851fn include_check(project: &Path, patterns: &[String]) -> Option<DoctorCheck> {
854 if patterns.is_empty() && !project.join(crate::include::FILE).is_file() {
855 return None;
856 }
857 let files = crate::include::matched(project, patterns);
858 let protected = crate::tools::paths::Protected::default();
859 let masked = files.iter().filter(|f| protected.is_secret(f)).count();
860 Some(if files.is_empty() {
861 warned(
862 "worktree include",
863 "nothing to copy: no git-ignored file matches [worktree] include or .worktreeinclude",
864 &[],
865 )
866 } else {
867 check(
868 "worktree include",
869 DoctorLevel::Pass,
870 format!(
871 "copied into each run's worktree: {}{}",
872 files.join(", "),
873 if masked == 0 {
874 String::new()
875 } else {
876 format!(
877 " ({masked} secret file(s): never committed, secret-named values masked)"
878 )
879 }
880 ),
881 )
882 })
883}
884
885fn untracked_check(root: &Path, feature: Option<&str>) -> Option<DoctorCheck> {
889 let out = Git::at(root)
890 .run(&[
892 "status",
893 "--porcelain",
894 "-z",
895 "--untracked-files=normal",
896 ".",
897 ])
898 .ok()?;
899 const OUTPUT: &[&str] = &[
901 ".DS_Store",
902 ".speccoder",
903 ".venv",
904 "venv",
905 "node_modules",
906 "target",
907 "__pycache__",
908 ".pytest_cache",
909 ".mypy_cache",
910 ".ruff_cache",
911 ".tox",
912 ".nox",
913 ".coverage",
914 "dist",
915 "build",
916 ".next",
917 ".turbo",
918 ".gradle",
919 ".idea",
920 ".vscode",
921 ];
922 let config = |p: &str| {
923 let name = p.trim_end_matches('/').rsplit('/').next().unwrap_or(p);
924 if OUTPUT.contains(&name) {
925 return false;
926 }
927 name.starts_with('.')
928 || [".toml", ".json", ".yaml", ".yml", ".cfg", ".ini"]
929 .iter()
930 .any(|e| name.ends_with(e))
931 };
932 let found: Vec<String> = String::from_utf8_lossy(&out)
933 .split('\0')
934 .filter_map(|l| l.strip_prefix("?? "))
935 .filter(|p| config(p))
936 .map(str::to_owned)
937 .collect();
938 if found.is_empty() {
939 return None;
940 }
941 let list = found.join(" ");
942 let mut add = ["git", "--literal-pathspecs", "add", "--"]
945 .map(str::to_owned)
946 .to_vec();
947 add.extend(found.iter().cloned());
948 let commit = ["git", "commit", "-m", "Track project config"]
949 .map(str::to_owned)
950 .to_vec();
951 let commands = vec![vec![add, commit]];
952 Some(DoctorCheck {
953 fix: commands
954 .iter()
955 .map(|c| speccoder_core::step::render(c))
956 .collect(),
957 commands,
958 remedy: crate::code::setup_remedy(root, feature),
960 ..check(
961 "untracked",
962 DoctorLevel::Warn,
963 format!(
964 "{list} — not committed, so a worktree does not have it; a check that needs it cannot run there"
965 ),
966 )
967 })
968}
969
970fn current_check(
973 format: &'static dyn crate::format::Adapter,
974 snap: &speccoder_core::snapshot::ProjectSnapshot,
975) -> DoctorCheck {
976 let unit = format.messages().unit;
977 let problem = snap
978 .diagnostics
979 .iter()
980 .find(|d| d.code == "select.ambiguous" || d.code == "select.not-found");
981 match (&snap.feature, problem) {
982 (Some(f), _) => check(
983 "feature",
984 DoctorLevel::Pass,
985 format!(
986 "{} ({})",
987 f.feature.name,
988 crate::current::reason_text(snap.selection.reason, format)
989 ),
990 ),
991 (None, Some(d)) => DoctorCheck {
992 fix: vec!["→ speccoder feature".to_owned()]
993 .into_iter()
994 .chain(d.hint.iter().map(|h| format!("→ {h}")))
995 .collect(),
996 ..check("feature", DoctorLevel::Warn, d.message.clone())
997 },
998 (None, None) if snap.features.is_empty() => check(
999 "feature",
1000 DoctorLevel::Pass,
1001 format!("no {unit}s yet — `speccoder spec specify \"…\"` creates one"),
1002 ),
1003 (None, None) => DoctorCheck {
1004 fix: vec!["→ speccoder feature".to_owned()],
1005 ..check("feature", DoctorLevel::Warn, format!("no current {unit}"))
1006 },
1007 }
1008}
1009
1010#[must_use]
1012pub fn redact(text: &str) -> String {
1013 let home = std::env::var("HOME").ok().filter(|h| h.len() > 1);
1014 let mut out = String::with_capacity(text.len());
1015 for word in
1016 text.split_inclusive(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '=')
1017 {
1018 let core =
1019 word.trim_end_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == '=');
1020 let secret = [
1021 "sk-ant-",
1022 "sk-proj-",
1023 "sk-",
1024 "ghp_",
1025 "github_pat_",
1026 "AKIA",
1027 "xoxb-",
1028 "glpat-",
1029 ]
1030 .iter()
1031 .any(|p| core.starts_with(p) && core.len() > p.len() + 8);
1032 if secret {
1033 out.push_str("[redacted]");
1034 out.push_str(&word[core.len()..]);
1035 } else {
1036 out.push_str(word);
1037 }
1038 }
1039 let out = crate::secrets::mask(&out);
1040 match home {
1041 Some(h) => out.replace(&h, "~"),
1042 None => out,
1043 }
1044}
1045
1046#[must_use]
1050pub fn bug_report(result: &CommandResult, root: &Path) -> String {
1051 let Some(CommandData::Doctor(d)) = result.data() else {
1052 return redact(result.message());
1053 };
1054 let mut out = format!(
1055 "### speccoder doctor\n\n- speccoder {}\n- {}\n\n| Check | Result | Detail |\n|---|---|---|\n",
1056 d.speccoder, d.os
1057 );
1058 for c in &d.checks {
1059 let level = match c.level {
1060 DoctorLevel::Pass => "ok",
1061 DoctorLevel::Info => "optional",
1062 DoctorLevel::Warn => "warning",
1063 DoctorLevel::Fail => "**fail**",
1064 };
1065 out.push_str(&format!(
1066 "| {} | {level}{} | {} |\n",
1067 c.check,
1068 c.code
1069 .as_deref()
1070 .map(|c| format!(" `{c}`"))
1071 .unwrap_or_default(),
1072 c.detail.replace('|', "\\|").replace('\n', " ")
1073 ));
1074 }
1075 let config = std::fs::read_to_string(root.join("speccoder.toml")).ok();
1076 if let Some(text) = config {
1077 out.push_str("\n<details><summary>speccoder.toml</summary>\n\n```toml\n");
1078 out.push_str(text.trim_end());
1079 out.push_str("\n```\n\n</details>\n");
1080 }
1081 redact(&out)
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 #[test]
1089 fn secrets_and_home_are_redacted() {
1090 let home = std::env::var("HOME").unwrap_or_default();
1091 let text = format!(
1092 "key sk-ant-api03-abcdefghijklmnop at {home}/x ghp_0123456789abcdefghij \"sk-proj-aaaaaaaaaaaaaaaa\""
1093 );
1094 let r = redact(&text);
1095 assert!(
1096 !r.contains("abcdefghijklmnop") && !r.contains("0123456789abcdef"),
1097 "{r}"
1098 );
1099 assert!(!r.contains("sk-proj-aaaa"), "{r}");
1100 if home.len() > 1 {
1101 assert!(!r.contains(&home) && r.contains("~/x"), "{r}");
1102 }
1103 assert!(r.contains("[redacted]"));
1104 assert_eq!(redact("sk-short"), "sk-short");
1105 }
1106
1107 #[test]
1108 fn the_files_a_worktree_gets_copied_are_named_and_nothing_is_copied() {
1109 let dir = tempfile::tempdir().unwrap();
1110 let r = dir.path();
1111 let git = |args: &[&str]| {
1112 assert!(
1113 Command::new("git")
1114 .args([
1115 "-c",
1116 "user.name=t",
1117 "-c",
1118 "user.email=t@e",
1119 "-c",
1120 "commit.gpgsign=false"
1121 ])
1122 .args(args)
1123 .current_dir(r)
1124 .status()
1125 .unwrap()
1126 .success()
1127 );
1128 };
1129 std::fs::write(r.join(".gitignore"), ".env\n*.local\n").unwrap();
1130 std::fs::write(r.join("app.local"), "x").unwrap();
1131 std::fs::write(r.join("tracked.local"), "x").unwrap();
1132 git(&["init", "-q"]);
1133 git(&["add", "-A"]);
1134 git(&["add", "-f", "tracked.local"]);
1135 git(&["commit", "-qm", "init"]);
1136 std::fs::write(r.join(".env"), "TOKEN=abcdefgh123\n").unwrap();
1137 assert!(include_check(r, &[]).is_none());
1138 let row = include_check(r, &[".env".to_owned()]).unwrap();
1139 assert_eq!(row.level, DoctorLevel::Pass);
1140 assert_eq!(
1141 row.detail,
1142 "copied into each run's worktree: .env (1 secret file(s): never committed, secret-named values masked)"
1143 );
1144 assert!(!row.detail.contains("abcdefgh123"));
1145 std::fs::write(r.join(".worktreeinclude"), "*.local\n").unwrap();
1147 let row = include_check(r, &[]).unwrap();
1148 assert_eq!(row.detail, "copied into each run's worktree: app.local");
1149 let row = include_check(r, &["nothing-here".to_owned()]).unwrap();
1150 assert_eq!(row.detail, "copied into each run's worktree: app.local");
1151 }
1152
1153 #[test]
1154 fn outside_any_repository_every_problem_is_named_with_its_fix() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let checks = checks(dir.path(), None, &|_| None);
1157 let failing: Vec<(&str, Option<&str>)> = checks
1158 .iter()
1159 .filter(|c| c.level == DoctorLevel::Fail)
1160 .map(|c| (c.check.as_str(), c.code.as_deref()))
1161 .collect();
1162 assert!(
1163 failing.contains(&("repository", Some("NO-REPOSITORY"))),
1164 "{failing:?}"
1165 );
1166 assert!(
1167 failing.contains(&("project", Some("NO-PROJECT"))),
1168 "{failing:?}"
1169 );
1170 assert!(
1171 failing.contains(&("config", Some("CONFIG-MISSING"))),
1172 "{failing:?}"
1173 );
1174 for c in checks.iter().filter(|c| c.level != DoctorLevel::Pass) {
1175 assert!(!c.fix.is_empty(), "{} has no fix", c.check);
1176 }
1177 }
1178}
1179
1180fn signing_fix(home: Option<&Path>) -> Vec<String> {
1183 let found = home.and_then(|h| {
1184 [
1185 "id_ed25519.pub",
1186 "id_ecdsa.pub",
1187 "id_ed25519_sk.pub",
1188 "id_ecdsa_sk.pub",
1189 "id_rsa.pub",
1190 ]
1191 .iter()
1192 .find(|k| h.join(".ssh").join(k).is_file())
1193 });
1194 let mut fix = Vec::new();
1195 let key = match found {
1196 Some(k) => format!("~/.ssh/{k}"),
1197 None => {
1198 fix.push("ssh-keygen -t ed25519".to_owned());
1199 "~/.ssh/id_ed25519.pub".to_owned()
1200 }
1201 };
1202 fix.push("git config --global gpg.format ssh".to_owned());
1203 fix.push(format!("git config --global user.signingKey {key}"));
1204 fix
1205}
1206
1207#[cfg(test)]
1208mod signing_tests {
1209 use super::*;
1210
1211 #[test]
1212 fn the_signing_fix_names_a_key_that_exists() {
1213 let home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1214 let none = signing_fix(Some(home.path()));
1215 assert_eq!(none[0], "ssh-keygen -t ed25519");
1216 std::fs::create_dir_all(home.path().join(".ssh")).unwrap_or_else(|e| panic!("{e}"));
1217 std::fs::write(home.path().join(".ssh/id_rsa.pub"), "ssh-rsa AAAA")
1218 .unwrap_or_else(|e| panic!("{e}"));
1219 let rsa = signing_fix(Some(home.path()));
1220 assert_eq!(
1221 rsa.last().map(String::as_str),
1222 Some("git config --global user.signingKey ~/.ssh/id_rsa.pub")
1223 );
1224 assert!(!rsa.iter().any(|f| f.starts_with("ssh-keygen")));
1225 }
1226}