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