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
150#[must_use]
152pub fn checks(
153 path: &Path,
154 feature: Option<&str>,
155 env: &dyn Fn(&str) -> Option<String>,
156) -> Vec<DoctorCheck> {
157 let mut out = Vec::new();
158 let git = version("git");
160 out.push(match &git {
161 Some(v) => check("git", DoctorLevel::Pass, v.clone()),
162 None => failed(
163 "git",
164 "GIT",
165 "git is not installed or does not run",
166 &["→ install git"],
167 ),
168 });
169 let top = Git::at(path).toplevel().ok().flatten();
170 let has_commit = top.as_ref().is_some_and(|t| {
171 Git::at(t)
172 .line(&["rev-parse", "--verify", "-q", "HEAD"])
173 .is_ok()
174 });
175 out.push(match (&top, has_commit) {
176 (Some(t), true) => check("repository", DoctorLevel::Pass, t.display().to_string()),
177 (Some(_), false) => failed(
178 "repository",
179 "NO-REPOSITORY",
180 "the repository has no commit yet",
181 &["git add -A && git commit -m init"],
182 ),
183 (None, _) => failed(
184 "repository",
185 "NO-REPOSITORY",
186 "not a git repository",
187 &["git init && git add -A && git commit -m init"],
188 ),
189 });
190 let opened = crate::format::open(path, feature);
192 let root = match &opened {
193 Ok(o) => {
194 let kind = o.adapter.wording().name;
195 let feature = o
196 .snapshot
197 .feature
198 .as_ref()
199 .map(|f| format!(", feature {}", f.feature.name))
200 .unwrap_or_default();
201 out.push(check(
202 "project",
203 DoctorLevel::Pass,
204 format!("{kind} at {}{feature}", o.snapshot.root),
205 ));
206 out.push(current_check(o.adapter, &o.snapshot));
207 let name = o.snapshot.feature.as_ref().map(|f| f.feature.name.as_str());
208 if let Some(c) = untracked_check(Path::new(&o.snapshot.root), name) {
209 out.push(c);
210 }
211 if let Some((tool, install)) = o.adapter.tool() {
212 out.push(match version(tool) {
213 Some(v) => check("spec tool", DoctorLevel::Pass, v),
214 None => DoctorCheck {
215 fix: vec![format!("→ {install}")],
216 ..check(
217 "spec tool",
218 DoctorLevel::Warn,
219 format!(
220 "`{tool}` is not on PATH: planning skills and cross-checks need it"
221 ),
222 )
223 },
224 });
225 }
226 if o.adapter.kind() == speccoder_core::spec::FormatKind::Speckit
228 && version("specify").is_some()
229 {
230 out.push(preset_check(Path::new(&o.snapshot.root)));
231 }
232 out.extend(o.adapter.doctor_checks(Path::new(&o.snapshot.root), env));
233 if o.snapshot.feature.is_some() {
235 out.extend(plan_checks(
236 o.adapter,
237 &o.snapshot,
238 Path::new(&o.snapshot.root),
239 ));
240 }
241 let root = std::path::PathBuf::from(&o.snapshot.root);
243 let layout = crate::layout::detect(&root, &crate::verify::check_argvs(&root));
244 if let Some(r) = layout.primary().filter(|r| r.story_dir.is_none()) {
245 out.push(warned(
246 "tests",
247 format!(
248 "no place the runner collects new test files, so no acceptance test is written: {}",
249 r.unknown.join("; ")
250 ),
251 &["→ let the runner collect a tests directory (Cargo: drop `autotests = false`; pytest: a `testpaths` entry that exists)"],
252 ));
253 }
254 root
255 }
256 Err(r) => {
257 let fix = speccoder_core::codes::lookup("NO-PROJECT")
258 .map(|e| speccoder_core::codes::fixes(e, "doctor", None, None))
259 .unwrap_or_default();
260 out.push(DoctorCheck {
261 code: Some("NO-PROJECT".to_owned()),
262 fix,
263 ..check("project", DoctorLevel::Fail, r.message.clone())
264 });
265 path.to_path_buf()
266 }
267 };
268 let config_dir = crate::verify::find_config(&root);
270 let text = config_dir
271 .as_ref()
272 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok());
273 let mut run_config = None;
274 let mut model = None;
275 match (&config_dir, &text) {
276 (Some(dir), Some(text)) => {
277 let parsed = crate::verify::config::parse(text)
278 .map(|_| ())
279 .and_then(|()| crate::substrate::model_config::parse(text).map(|m| model = m))
280 .and_then(|()| {
281 crate::substrate::run_config::parse(text).map(|c| run_config = Some(c))
282 });
283 match parsed {
284 Err(e) => out.push(failed(
285 "config",
286 "CONFIG-INVALID",
287 e,
288 &["→ fix the line the message names in speccoder.toml"],
289 )),
290 Ok(()) => {
291 let file = dir.join(crate::verify::config::FILE);
292 let g = Git::at(dir);
293 let tracked = g
294 .succeeds(&[
295 "ls-files",
296 "--error-unmatch",
297 "--",
298 crate::verify::config::FILE,
299 ])
300 .unwrap_or(false);
301 let clean = g
302 .succeeds(&["diff", "--quiet", "HEAD", "--", crate::verify::config::FILE])
303 .unwrap_or(false);
304 out.push(if tracked && clean {
305 check("config", DoctorLevel::Pass, file.display().to_string())
306 } else {
307 failed(
308 "config",
309 "CONFIG-NOT-TRACKED",
310 format!("{} is not committed as it is: speccoder reads committed files only", file.display()),
311 &["git add speccoder.toml && git commit -m \"speccoder: config\""],
312 )
313 });
314 }
315 }
316 }
317 _ => out.push(failed(
318 "config",
319 "CONFIG-MISSING",
320 "no speccoder.toml",
321 &["speccoder init"],
322 )),
323 }
324 if let Some(m) = &model {
326 out.push(check(
327 "model",
328 DoctorLevel::Pass,
329 format!("{}/{}", m.provider, m.model),
330 ));
331 let variable = m.key_variable().to_owned();
332 out.push(
333 if variable.is_empty() || env(&variable).is_some_and(|v| !v.is_empty()) {
334 check(
335 "model key",
336 DoctorLevel::Pass,
337 format!("{} {}", m.provider, m.model),
338 )
339 } else {
340 DoctorCheck {
341 code: Some("KEY-MISSING".to_owned()),
342 fix: vec![format!("export {variable}=…")],
343 ..check(
344 "model key",
345 DoctorLevel::Fail,
346 format!("{variable} is not set"),
347 )
348 }
349 },
350 );
351 }
352 let sandbox = text
354 .as_deref()
355 .and_then(|t| crate::sandbox::parse(t).ok())
356 .unwrap_or_default();
357 out.push(match (crate::sandbox::backend(), sandbox.mode) {
358 (Ok(b), _) => check(
359 "sandbox",
360 DoctorLevel::Pass,
361 format!("{b:?}").to_lowercase(),
362 ),
363 (Err(e), crate::sandbox::Mode::Required) => failed(
364 "sandbox",
365 "SANDBOX-UNAVAILABLE",
366 e,
367 &["→ run where the OS sandbox exists, or set [sandbox] mode = \"auto\""],
368 ),
369 (Err(e), crate::sandbox::Mode::Off) => {
370 check("sandbox", DoctorLevel::Warn, format!("off ({e})"))
371 }
372 (Err(e), crate::sandbox::Mode::Auto) => warned(
373 "sandbox",
374 format!("not available, commands run unsandboxed: {e}"),
375 &["→ prefer a container or VM without your credentials"],
376 ),
377 });
378 out.push(match crate::worktree::state_dir() {
380 Some(dir) => {
381 let existing = dir.ancestors().find(|a| a.exists());
386 let writable = existing
387 .filter(|a| a.is_dir())
388 .is_some_and(|a| tempfile::tempfile_in(a).is_ok());
389 if writable {
390 check("state", DoctorLevel::Pass, dir.display().to_string())
391 } else {
392 failed(
393 "state",
394 "NO-STATE-DIR",
395 format!("{} is not writable", dir.display()),
396 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
397 )
398 }
399 }
400 None => failed(
401 "state",
402 "NO-STATE-DIR",
403 "no state directory (SPECCODER_STATE_DIR, XDG_STATE_HOME and HOME are unset)",
404 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
405 ),
406 });
407 if let Some(dir) = crate::worktree::state_dir() {
410 let project = crate::worktree::project_state_dir(&dir, &root);
411 if let Err(e) = crate::approvals::Decisions::load(&project) {
412 out.push(failed(
413 "decisions",
414 "CONFIG-INVALID",
415 e,
416 &[&format!(
417 "rm {} (it predates this version; its decisions are not carried over)",
418 project.join(crate::approvals::FILE).display()
419 )],
420 ));
421 }
422 }
423 let attest = run_config
425 .as_ref()
426 .map(|c| c.attest.clone())
427 .unwrap_or_default();
428 if attest.enabled {
429 let repo = top.clone().unwrap_or_else(|| root.clone());
430 out.push(match crate::attest::sign::signer(&repo, &attest) {
431 Ok(s) => check(
432 "signing",
433 DoctorLevel::Pass,
434 format!("attestations signed with {}", s.kind()),
435 ),
436 Err(e) => {
437 let fix = signing_fix(
438 std::env::var_os("HOME")
439 .map(std::path::PathBuf::from)
440 .as_deref(),
441 );
442 DoctorCheck {
444 fix,
445 ..check(
446 "signing",
447 DoctorLevel::Info,
448 format!("attestations are not signed (optional): {e}"),
449 )
450 }
451 }
452 });
453 }
454 if let Ok(r) = crate::github::repo(&root, None) {
457 let ctx = crate::github::Ctx::from_env();
458 out.push(match ctx.token(&r.host) {
459 Some((_, source)) => check(
460 "github",
461 DoctorLevel::Pass,
462 format!("{} on {} (token from {})", r.slug(), r.host, source.word()),
463 ),
464 None => DoctorCheck {
465 fix: vec!["speccoder auth login --with-token".to_owned()],
466 ..check(
467 "github",
468 DoctorLevel::Info,
469 format!(
470 "not signed in to {} (optional: for issues and pull requests)",
471 r.host
472 ),
473 )
474 },
475 });
476 }
477 let names = run_config
479 .as_ref()
480 .map(|c| c.instructions.clone())
481 .unwrap_or_else(|| vec!["AGENTS.md".to_owned()]);
482 let repo = top.unwrap_or_else(|| root.clone());
483 let given = crate::instructions::collect(&repo, &root, &[], &names);
484 out.push(if !given.is_empty() {
485 check(
486 "instructions",
487 DoctorLevel::Pass,
488 given
489 .iter()
490 .map(|i| i.path.clone())
491 .collect::<Vec<_>>()
492 .join(", "),
493 )
494 } else if repo.join("CLAUDE.md").is_file() && !names.iter().any(|n| n == "CLAUDE.md") {
495 warned(
496 "instructions",
497 "CLAUDE.md is here but not read: attempts get no repository instructions",
498 &["→ add [context] instructions = [\"AGENTS.md\", \"CLAUDE.md\"] to speccoder.toml"],
499 )
500 } else {
501 check("instructions", DoctorLevel::Pass, "none (no AGENTS.md)")
502 });
503 out
504}
505
506#[must_use]
509pub fn run(path: &Path, feature: Option<&str>, look: Option<&str>) -> CommandResult {
510 let mut checks = checks(path, feature, &|k| std::env::var(k).ok());
511 if let Some(look) = look {
512 checks.push(check("look", DoctorLevel::Pass, look));
513 }
514 let failures: Vec<&DoctorCheck> = checks
515 .iter()
516 .filter(|c| c.level == DoctorLevel::Fail)
517 .collect();
518 let warnings = checks
519 .iter()
520 .filter(|c| c.level == DoctorLevel::Warn)
521 .count();
522 let optional = checks
523 .iter()
524 .filter(|c| c.level == DoctorLevel::Info)
525 .count();
526 let report = DoctorReport {
527 speccoder: env!("CARGO_PKG_VERSION").to_owned(),
528 os: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
529 checks: checks.clone(),
530 };
531 let result = match failures.first() {
532 None => CommandResult::ok(
533 "doctor",
534 &format!(
535 "{} checks passed, {warnings} warning(s){}",
536 checks.len() - warnings - optional,
537 if optional > 0 {
538 format!(", {optional} optional")
539 } else {
540 String::new()
541 }
542 ),
543 ),
544 Some(first) => {
545 let entry = first
546 .code
547 .as_deref()
548 .and_then(speccoder_core::codes::lookup);
549 let status = entry.map_or(CommandStatus::Error, |e| e.status);
550 let message = format!(
551 "{} problem(s): {}",
552 failures.len(),
553 failures
554 .iter()
555 .map(|f| f.check.as_str())
556 .collect::<Vec<_>>()
557 .join(", ")
558 );
559 let mut r = CommandResult::new("doctor", status, &message).with_fix(first.fix.clone());
560 if let Some(code) = &first.code {
561 r = r.with_code(code);
562 }
563 r
564 }
565 };
566 result.with_data(CommandData::Doctor(Box::new(report)))
567}
568
569fn untracked_check(root: &Path, feature: Option<&str>) -> Option<DoctorCheck> {
573 let out = Git::at(root)
574 .run(&["status", "--porcelain", "--untracked-files=normal", "."])
575 .ok()?;
576 const OUTPUT: &[&str] = &[
578 ".DS_Store",
579 ".speccoder",
580 ".venv",
581 "venv",
582 "node_modules",
583 "target",
584 "__pycache__",
585 ".pytest_cache",
586 ".mypy_cache",
587 ".ruff_cache",
588 ".tox",
589 ".nox",
590 ".coverage",
591 "dist",
592 "build",
593 ".next",
594 ".turbo",
595 ".gradle",
596 ".idea",
597 ".vscode",
598 ];
599 let config = |p: &str| {
600 let name = p.trim_end_matches('/').rsplit('/').next().unwrap_or(p);
601 if OUTPUT.contains(&name) {
602 return false;
603 }
604 name.starts_with('.')
605 || [".toml", ".json", ".yaml", ".yml", ".cfg", ".ini"]
606 .iter()
607 .any(|e| name.ends_with(e))
608 };
609 let found: Vec<String> = String::from_utf8_lossy(&out)
610 .lines()
611 .filter_map(|l| l.strip_prefix("?? "))
612 .filter(|p| config(p))
613 .map(str::to_owned)
614 .collect();
615 if found.is_empty() {
616 return None;
617 }
618 let list = found.join(" ");
619 Some(DoctorCheck {
620 fix: vec![format!(
621 "git add {list} && git commit -m \"Track project config\" (if the checks need them)"
622 )],
623 remedy: crate::code::setup_remedy(root, feature),
625 ..check(
626 "untracked",
627 DoctorLevel::Warn,
628 format!(
629 "{list} — not committed, so a worktree does not have it; a check that needs it cannot run there"
630 ),
631 )
632 })
633}
634
635fn current_check(
638 format: &'static dyn crate::format::Adapter,
639 snap: &speccoder_core::snapshot::ProjectSnapshot,
640) -> DoctorCheck {
641 let unit = format.messages().unit;
642 let problem = snap
643 .diagnostics
644 .iter()
645 .find(|d| d.code == "select.ambiguous" || d.code == "select.not-found");
646 match (&snap.feature, problem) {
647 (Some(f), _) => check(
648 "feature",
649 DoctorLevel::Pass,
650 format!(
651 "{} ({})",
652 f.feature.name,
653 crate::current::reason_text(snap.selection.reason, format)
654 ),
655 ),
656 (None, Some(d)) => DoctorCheck {
657 fix: vec!["→ speccoder feature".to_owned()]
658 .into_iter()
659 .chain(d.hint.iter().map(|h| format!("→ {h}")))
660 .collect(),
661 ..check("feature", DoctorLevel::Warn, d.message.clone())
662 },
663 (None, None) if snap.features.is_empty() => check(
664 "feature",
665 DoctorLevel::Pass,
666 format!("no {unit}s yet — `speccoder spec specify \"…\"` creates one"),
667 ),
668 (None, None) => DoctorCheck {
669 fix: vec!["→ speccoder feature".to_owned()],
670 ..check("feature", DoctorLevel::Warn, format!("no current {unit}"))
671 },
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn outside_any_repository_every_problem_is_named_with_its_fix() {
681 let dir = tempfile::tempdir().unwrap();
682 let checks = checks(dir.path(), None, &|_| None);
683 let failing: Vec<(&str, Option<&str>)> = checks
684 .iter()
685 .filter(|c| c.level == DoctorLevel::Fail)
686 .map(|c| (c.check.as_str(), c.code.as_deref()))
687 .collect();
688 assert!(
689 failing.contains(&("repository", Some("NO-REPOSITORY"))),
690 "{failing:?}"
691 );
692 assert!(
693 failing.contains(&("project", Some("NO-PROJECT"))),
694 "{failing:?}"
695 );
696 assert!(
697 failing.contains(&("config", Some("CONFIG-MISSING"))),
698 "{failing:?}"
699 );
700 for c in checks.iter().filter(|c| c.level != DoctorLevel::Pass) {
701 assert!(!c.fix.is_empty(), "{} has no fix", c.check);
702 }
703 }
704}
705
706fn signing_fix(home: Option<&Path>) -> Vec<String> {
709 let found = home.and_then(|h| {
710 [
711 "id_ed25519.pub",
712 "id_ecdsa.pub",
713 "id_ed25519_sk.pub",
714 "id_ecdsa_sk.pub",
715 "id_rsa.pub",
716 ]
717 .iter()
718 .find(|k| h.join(".ssh").join(k).is_file())
719 });
720 let mut fix = Vec::new();
721 let key = match found {
722 Some(k) => format!("~/.ssh/{k}"),
723 None => {
724 fix.push("ssh-keygen -t ed25519".to_owned());
725 "~/.ssh/id_ed25519.pub".to_owned()
726 }
727 };
728 fix.push("git config --global gpg.format ssh".to_owned());
729 fix.push(format!("git config --global user.signingKey {key}"));
730 fix
731}
732
733#[cfg(test)]
734mod signing_tests {
735 use super::*;
736
737 #[test]
738 fn the_signing_fix_names_a_key_that_exists() {
739 let home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
740 let none = signing_fix(Some(home.path()));
741 assert_eq!(none[0], "ssh-keygen -t ed25519");
742 std::fs::create_dir_all(home.path().join(".ssh")).unwrap_or_else(|e| panic!("{e}"));
743 std::fs::write(home.path().join(".ssh/id_rsa.pub"), "ssh-rsa AAAA")
744 .unwrap_or_else(|e| panic!("{e}"));
745 let rsa = signing_fix(Some(home.path()));
746 assert_eq!(
747 rsa.last().map(String::as_str),
748 Some("git config --global user.signingKey ~/.ssh/id_rsa.pub")
749 );
750 assert!(!rsa.iter().any(|f| f.starts_with("ssh-keygen")));
751 }
752}