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