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