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 }
26}
27
28fn failed(name: &str, code: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
29 DoctorCheck {
30 code: Some(code.to_owned()),
31 fix: fix.iter().map(|f| (*f).to_owned()).collect(),
32 ..check(name, DoctorLevel::Fail, detail)
33 }
34}
35
36fn warned(name: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
37 DoctorCheck {
38 fix: fix.iter().map(|f| (*f).to_owned()).collect(),
39 ..check(name, DoctorLevel::Warn, detail)
40 }
41}
42
43fn version(program: &str) -> Option<String> {
45 let out = Command::new(program).arg("--version").output().ok()?;
46 out.status.success().then(|| {
47 String::from_utf8_lossy(&out.stdout)
48 .lines()
49 .next()
50 .unwrap_or_default()
51 .trim()
52 .to_owned()
53 })
54}
55
56#[must_use]
58pub fn checks(
59 path: &Path,
60 feature: Option<&str>,
61 env: &dyn Fn(&str) -> Option<String>,
62) -> Vec<DoctorCheck> {
63 let mut out = Vec::new();
64 let git = version("git");
66 out.push(match &git {
67 Some(v) => check("git", DoctorLevel::Pass, v.clone()),
68 None => failed(
69 "git",
70 "GIT",
71 "git is not installed or does not run",
72 &["→ install git"],
73 ),
74 });
75 let top = Git::at(path).toplevel().ok().flatten();
76 let has_commit = top.as_ref().is_some_and(|t| {
77 Git::at(t)
78 .line(&["rev-parse", "--verify", "-q", "HEAD"])
79 .is_ok()
80 });
81 out.push(match (&top, has_commit) {
82 (Some(t), true) => check("repository", DoctorLevel::Pass, t.display().to_string()),
83 (Some(_), false) => failed(
84 "repository",
85 "NO-REPOSITORY",
86 "the repository has no commit yet",
87 &["git add -A && git commit -m init"],
88 ),
89 (None, _) => failed(
90 "repository",
91 "NO-REPOSITORY",
92 "not a git repository",
93 &["git init && git add -A && git commit -m init"],
94 ),
95 });
96 let opened = crate::format::open(path, feature);
98 let root = match &opened {
99 Ok(o) => {
100 let kind = o.adapter.wording().name;
101 let feature = o
102 .snapshot
103 .feature
104 .as_ref()
105 .map(|f| format!(", feature {}", f.feature.name))
106 .unwrap_or_default();
107 out.push(check(
108 "project",
109 DoctorLevel::Pass,
110 format!("{kind} at {}{feature}", o.snapshot.root),
111 ));
112 out.push(current_check(o.adapter, &o.snapshot));
113 let name = o.snapshot.feature.as_ref().map(|f| f.feature.name.as_str());
114 if let Some(c) = untracked_check(Path::new(&o.snapshot.root), name) {
115 out.push(c);
116 }
117 if let Some((tool, install)) = o.adapter.tool() {
118 out.push(match version(tool) {
119 Some(v) => check("spec tool", DoctorLevel::Pass, v),
120 None => DoctorCheck {
121 fix: vec![format!("→ {install}")],
122 ..check(
123 "spec tool",
124 DoctorLevel::Warn,
125 format!(
126 "`{tool}` is not on PATH: planning skills and cross-checks need it"
127 ),
128 )
129 },
130 });
131 }
132 std::path::PathBuf::from(&o.snapshot.root)
133 }
134 Err(r) => {
135 let fix = speccoder_core::codes::lookup("NO-PROJECT")
136 .map(|e| speccoder_core::codes::fixes(e, "doctor", None, None))
137 .unwrap_or_default();
138 out.push(DoctorCheck {
139 code: Some("NO-PROJECT".to_owned()),
140 fix,
141 ..check("project", DoctorLevel::Fail, r.message.clone())
142 });
143 path.to_path_buf()
144 }
145 };
146 let config_dir = crate::verify::find_config(&root);
148 let text = config_dir
149 .as_ref()
150 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok());
151 let mut run_config = None;
152 let mut model = None;
153 match (&config_dir, &text) {
154 (Some(dir), Some(text)) => {
155 let parsed = crate::verify::config::parse(text)
156 .map(|_| ())
157 .and_then(|()| crate::substrate::model_config::parse(text).map(|m| model = m))
158 .and_then(|()| {
159 crate::substrate::run_config::parse(text).map(|c| run_config = Some(c))
160 });
161 match parsed {
162 Err(e) => out.push(failed(
163 "config",
164 "CONFIG-INVALID",
165 e,
166 &["→ fix the line the message names in speccoder.toml"],
167 )),
168 Ok(()) => {
169 let file = dir.join(crate::verify::config::FILE);
170 let g = Git::at(dir);
171 let tracked = g
172 .succeeds(&[
173 "ls-files",
174 "--error-unmatch",
175 "--",
176 crate::verify::config::FILE,
177 ])
178 .unwrap_or(false);
179 let clean = g
180 .succeeds(&["diff", "--quiet", "HEAD", "--", crate::verify::config::FILE])
181 .unwrap_or(false);
182 out.push(if tracked && clean {
183 check("config", DoctorLevel::Pass, file.display().to_string())
184 } else {
185 failed(
186 "config",
187 "CONFIG-NOT-TRACKED",
188 format!("{} is not committed as it is: speccoder reads committed files only", file.display()),
189 &["git add speccoder.toml && git commit -m \"speccoder: config\""],
190 )
191 });
192 }
193 }
194 }
195 _ => out.push(failed(
196 "config",
197 "CONFIG-MISSING",
198 "no speccoder.toml",
199 &["speccoder init"],
200 )),
201 }
202 if let Some(m) = &model {
204 out.push(check(
205 "model",
206 DoctorLevel::Pass,
207 format!("{}/{}", m.provider, m.model),
208 ));
209 let variable = m.key_variable().to_owned();
210 out.push(
211 if variable.is_empty() || env(&variable).is_some_and(|v| !v.is_empty()) {
212 check(
213 "model key",
214 DoctorLevel::Pass,
215 format!("{} {}", m.provider, m.model),
216 )
217 } else {
218 DoctorCheck {
219 code: Some("KEY-MISSING".to_owned()),
220 fix: vec![format!("export {variable}=…")],
221 ..check(
222 "model key",
223 DoctorLevel::Fail,
224 format!("{variable} is not set"),
225 )
226 }
227 },
228 );
229 }
230 let sandbox = text
232 .as_deref()
233 .and_then(|t| crate::sandbox::parse(t).ok())
234 .unwrap_or_default();
235 out.push(match (crate::sandbox::backend(), sandbox.mode) {
236 (Ok(b), _) => check(
237 "sandbox",
238 DoctorLevel::Pass,
239 format!("{b:?}").to_lowercase(),
240 ),
241 (Err(e), crate::sandbox::Mode::Required) => failed(
242 "sandbox",
243 "SANDBOX-UNAVAILABLE",
244 e,
245 &["→ run where the OS sandbox exists, or set [sandbox] mode = \"auto\""],
246 ),
247 (Err(e), crate::sandbox::Mode::Off) => {
248 check("sandbox", DoctorLevel::Warn, format!("off ({e})"))
249 }
250 (Err(e), crate::sandbox::Mode::Auto) => warned(
251 "sandbox",
252 format!("not available, commands run unsandboxed: {e}"),
253 &["→ prefer a container or VM without your credentials"],
254 ),
255 });
256 out.push(match crate::worktree::state_dir() {
258 Some(dir) => {
259 let writable =
260 std::fs::create_dir_all(&dir).is_ok() && tempfile::tempfile_in(&dir).is_ok();
261 if writable {
262 check("state", DoctorLevel::Pass, dir.display().to_string())
263 } else {
264 failed(
265 "state",
266 "NO-STATE-DIR",
267 format!("{} is not writable", dir.display()),
268 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
269 )
270 }
271 }
272 None => failed(
273 "state",
274 "NO-STATE-DIR",
275 "no state directory (SPECCODER_STATE_DIR, XDG_STATE_HOME and HOME are unset)",
276 &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
277 ),
278 });
279 if let Some(dir) = crate::worktree::state_dir() {
282 let project = crate::worktree::project_state_dir(&dir, &root);
283 if let Err(e) = crate::approvals::Decisions::load(&project) {
284 out.push(failed(
285 "decisions",
286 "CONFIG-INVALID",
287 e,
288 &[&format!(
289 "rm {} (it predates this version; its decisions are not carried over)",
290 project.join(crate::approvals::FILE).display()
291 )],
292 ));
293 }
294 }
295 let attest = run_config
297 .as_ref()
298 .map(|c| c.attest.clone())
299 .unwrap_or_default();
300 if attest.enabled {
301 let repo = top.clone().unwrap_or_else(|| root.clone());
302 out.push(match crate::attest::sign::signer(&repo, &attest) {
303 Ok(s) => check(
304 "signing",
305 DoctorLevel::Pass,
306 format!("attestations signed with {}", s.kind()),
307 ),
308 Err(e) => {
309 let fix = signing_fix(
310 std::env::var_os("HOME")
311 .map(std::path::PathBuf::from)
312 .as_deref(),
313 );
314 DoctorCheck {
316 fix,
317 ..check(
318 "signing",
319 DoctorLevel::Info,
320 format!("attestations are not signed (optional): {e}"),
321 )
322 }
323 }
324 });
325 }
326 if let Ok(r) = crate::github::repo(&root, None) {
329 let ctx = crate::github::Ctx::from_env();
330 out.push(match ctx.token(&r.host) {
331 Some((_, source)) => check(
332 "github",
333 DoctorLevel::Pass,
334 format!("{} on {} (token from {})", r.slug(), r.host, source.word()),
335 ),
336 None => DoctorCheck {
337 fix: vec!["speccoder auth login --with-token".to_owned()],
338 ..check(
339 "github",
340 DoctorLevel::Info,
341 format!(
342 "not signed in to {} (optional: for issues and pull requests)",
343 r.host
344 ),
345 )
346 },
347 });
348 }
349 let names = run_config
351 .as_ref()
352 .map(|c| c.instructions.clone())
353 .unwrap_or_else(|| vec!["AGENTS.md".to_owned()]);
354 let repo = top.unwrap_or_else(|| root.clone());
355 let given = crate::instructions::collect(&repo, &root, &[], &names);
356 out.push(if !given.is_empty() {
357 check(
358 "instructions",
359 DoctorLevel::Pass,
360 given
361 .iter()
362 .map(|i| i.path.clone())
363 .collect::<Vec<_>>()
364 .join(", "),
365 )
366 } else if repo.join("CLAUDE.md").is_file() && !names.iter().any(|n| n == "CLAUDE.md") {
367 warned(
368 "instructions",
369 "CLAUDE.md is here but not read: attempts get no repository instructions",
370 &["→ add [context] instructions = [\"AGENTS.md\", \"CLAUDE.md\"] to speccoder.toml"],
371 )
372 } else {
373 check("instructions", DoctorLevel::Pass, "none (no AGENTS.md)")
374 });
375 out
376}
377
378#[must_use]
381pub fn run(path: &Path, feature: Option<&str>, look: Option<&str>) -> CommandResult {
382 let mut checks = checks(path, feature, &|k| std::env::var(k).ok());
383 if let Some(look) = look {
384 checks.push(check("look", DoctorLevel::Pass, look));
385 }
386 let failures: Vec<&DoctorCheck> = checks
387 .iter()
388 .filter(|c| c.level == DoctorLevel::Fail)
389 .collect();
390 let warnings = checks
391 .iter()
392 .filter(|c| c.level == DoctorLevel::Warn)
393 .count();
394 let optional = checks
395 .iter()
396 .filter(|c| c.level == DoctorLevel::Info)
397 .count();
398 let report = DoctorReport {
399 speccoder: env!("CARGO_PKG_VERSION").to_owned(),
400 os: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
401 checks: checks.clone(),
402 };
403 let result = match failures.first() {
404 None => CommandResult::ok(
405 "doctor",
406 &format!(
407 "{} checks passed, {warnings} warning(s){}",
408 checks.len() - warnings - optional,
409 if optional > 0 {
410 format!(", {optional} optional")
411 } else {
412 String::new()
413 }
414 ),
415 ),
416 Some(first) => {
417 let entry = first
418 .code
419 .as_deref()
420 .and_then(speccoder_core::codes::lookup);
421 let status = entry.map_or(CommandStatus::Error, |e| e.status);
422 let message = format!(
423 "{} problem(s): {}",
424 failures.len(),
425 failures
426 .iter()
427 .map(|f| f.check.as_str())
428 .collect::<Vec<_>>()
429 .join(", ")
430 );
431 let mut r = CommandResult::new("doctor", status, &message).with_fix(first.fix.clone());
432 if let Some(code) = &first.code {
433 r = r.with_code(code);
434 }
435 r
436 }
437 };
438 result.with_data(CommandData::Doctor(Box::new(report)))
439}
440
441fn untracked_check(root: &Path, feature: Option<&str>) -> Option<DoctorCheck> {
445 let out = Git::at(root)
446 .run(&["status", "--porcelain", "--untracked-files=normal", "."])
447 .ok()?;
448 const OUTPUT: &[&str] = &[
450 ".DS_Store",
451 ".speccoder",
452 ".venv",
453 "venv",
454 "node_modules",
455 "target",
456 "__pycache__",
457 ".pytest_cache",
458 ".mypy_cache",
459 ".ruff_cache",
460 ".tox",
461 ".nox",
462 ".coverage",
463 "dist",
464 "build",
465 ".next",
466 ".turbo",
467 ".gradle",
468 ".idea",
469 ".vscode",
470 ];
471 let config = |p: &str| {
472 let name = p.trim_end_matches('/').rsplit('/').next().unwrap_or(p);
473 if OUTPUT.contains(&name) {
474 return false;
475 }
476 name.starts_with('.')
477 || [".toml", ".json", ".yaml", ".yml", ".cfg", ".ini"]
478 .iter()
479 .any(|e| name.ends_with(e))
480 };
481 let found: Vec<String> = String::from_utf8_lossy(&out)
482 .lines()
483 .filter_map(|l| l.strip_prefix("?? "))
484 .filter(|p| config(p))
485 .map(str::to_owned)
486 .collect();
487 if found.is_empty() {
488 return None;
489 }
490 let list = found.join(" ");
491 Some(DoctorCheck {
492 fix: vec![format!(
493 "git add {list} && git commit -m \"Track project config\" (if the checks need them)"
494 )],
495 remedy: crate::code::setup_remedy(root, feature),
497 ..check(
498 "untracked",
499 DoctorLevel::Warn,
500 format!(
501 "{list} — not committed, so a worktree does not have it; a check that needs it cannot run there"
502 ),
503 )
504 })
505}
506
507fn current_check(
510 format: &'static dyn crate::format::Adapter,
511 snap: &speccoder_core::snapshot::ProjectSnapshot,
512) -> DoctorCheck {
513 let unit = format.messages().unit;
514 let problem = snap
515 .diagnostics
516 .iter()
517 .find(|d| d.code == "select.ambiguous" || d.code == "select.not-found");
518 match (&snap.feature, problem) {
519 (Some(f), _) => check(
520 "feature",
521 DoctorLevel::Pass,
522 format!(
523 "{} ({})",
524 f.feature.name,
525 crate::current::reason_text(snap.selection.reason, format)
526 ),
527 ),
528 (None, Some(d)) => DoctorCheck {
529 fix: vec!["→ speccoder feature".to_owned()]
530 .into_iter()
531 .chain(d.hint.iter().map(|h| format!("→ {h}")))
532 .collect(),
533 ..check("feature", DoctorLevel::Warn, d.message.clone())
534 },
535 (None, None) if snap.features.is_empty() => check(
536 "feature",
537 DoctorLevel::Pass,
538 format!("no {unit}s yet — `speccoder spec specify \"…\"` creates one"),
539 ),
540 (None, None) => DoctorCheck {
541 fix: vec!["→ speccoder feature".to_owned()],
542 ..check("feature", DoctorLevel::Warn, format!("no current {unit}"))
543 },
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 #[test]
552 fn outside_any_repository_every_problem_is_named_with_its_fix() {
553 let dir = tempfile::tempdir().unwrap();
554 let checks = checks(dir.path(), None, &|_| None);
555 let failing: Vec<(&str, Option<&str>)> = checks
556 .iter()
557 .filter(|c| c.level == DoctorLevel::Fail)
558 .map(|c| (c.check.as_str(), c.code.as_deref()))
559 .collect();
560 assert!(
561 failing.contains(&("repository", Some("NO-REPOSITORY"))),
562 "{failing:?}"
563 );
564 assert!(
565 failing.contains(&("project", Some("NO-PROJECT"))),
566 "{failing:?}"
567 );
568 assert!(
569 failing.contains(&("config", Some("CONFIG-MISSING"))),
570 "{failing:?}"
571 );
572 for c in checks.iter().filter(|c| c.level != DoctorLevel::Pass) {
573 assert!(!c.fix.is_empty(), "{} has no fix", c.check);
574 }
575 }
576}
577
578fn signing_fix(home: Option<&Path>) -> Vec<String> {
581 let found = home.and_then(|h| {
582 [
583 "id_ed25519.pub",
584 "id_ecdsa.pub",
585 "id_ed25519_sk.pub",
586 "id_ecdsa_sk.pub",
587 "id_rsa.pub",
588 ]
589 .iter()
590 .find(|k| h.join(".ssh").join(k).is_file())
591 });
592 let mut fix = Vec::new();
593 let key = match found {
594 Some(k) => format!("~/.ssh/{k}"),
595 None => {
596 fix.push("ssh-keygen -t ed25519".to_owned());
597 "~/.ssh/id_ed25519.pub".to_owned()
598 }
599 };
600 fix.push("git config --global gpg.format ssh".to_owned());
601 fix.push(format!("git config --global user.signingKey {key}"));
602 fix
603}
604
605#[cfg(test)]
606mod signing_tests {
607 use super::*;
608
609 #[test]
610 fn the_signing_fix_names_a_key_that_exists() {
611 let home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
612 let none = signing_fix(Some(home.path()));
613 assert_eq!(none[0], "ssh-keygen -t ed25519");
614 std::fs::create_dir_all(home.path().join(".ssh")).unwrap_or_else(|e| panic!("{e}"));
615 std::fs::write(home.path().join(".ssh/id_rsa.pub"), "ssh-rsa AAAA")
616 .unwrap_or_else(|e| panic!("{e}"));
617 let rsa = signing_fix(Some(home.path()));
618 assert_eq!(
619 rsa.last().map(String::as_str),
620 Some("git config --global user.signingKey ~/.ssh/id_rsa.pub")
621 );
622 assert!(!rsa.iter().any(|f| f.starts_with("ssh-keygen")));
623 }
624}