Skip to main content

speccoder_runtime/
doctor.rs

1//! `speccoder doctor`: everything a run depends on, checked
2//! without writing anything — git, the repository, the spec project and
3//! its tool, the configuration, the model key, the sandbox, the state
4//! directory, the attestation signing key and the repository's
5//! instructions. A failure is a run that would fail or judge wrongly; a
6//! warning is less than it could be (unsigned evidence, no spec tool).
7
8use 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/// The plan checks as doctor rows: `check: "plan"`, level `warn`, the
46/// code's catalog entry, the tasks, the fixes and, for behaviour planned
47/// early, the decision speccoder records as the remedy.
48#[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
88/// Whether the speccoder preset is active for the tasks template.
89fn 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
138/// The first line a program prints for `--version`, if it runs.
139fn 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
151/// Bedrock's `model key` row: the region and where the AWS chain will take
152/// credentials from, named offline (never a value, never a call); and a
153/// `model region` warning when `AWS_REGION` names another region than
154/// the file, which wins.
155fn 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/// Every check, in order, for the project at `path`.
199#[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    // git and the repository.
207    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    // The spec project, and its own tool.
239    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            // The speccoder preset, as Spec Kit reports it.
275            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            // The plan checks of the selected feature: warnings.
282            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                // Who does what: information, never a warning.
289                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            // Where speccoder's acceptance tests would go.
299            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    // The configuration: present, valid, committed.
326    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    // The model, then its key.
382    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    // The sandbox.
414    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    // The person's package caches: read-only to what a run executes.
440    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    // What runs without asking.
448    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    // `[worktree] include` and `.worktreeinclude`: what a run's worktree
459    // gets copied — names only, nothing copied.
460    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    // The state directory.
470    out.push(match crate::worktree::state_dir() {
471        Some(dir) => {
472            // Probed without creating it: a check writes nothing of its
473            // own — the directory, or the nearest folder it would be made
474            // in. The nearest path that exists must be a folder: a file
475            // there (the directory itself, or a parent) blocks creating it.
476            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    // A person's decisions: a file from before decisions named objects is
499    // refused by every run (never read as "no decisions").
500    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    // The attestation signing key.
515    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                // Optional: every tick is attested either way; a key signs it.
534                DoctorCheck {
535                    fix,
536                    ..check(
537                        "signing",
538                        DoctorLevel::Info,
539                        format!("attestations are not signed (optional): {e}"),
540                    )
541                }
542            }
543        });
544    }
545    // GitHub (issues, pull requests): offline — the host the remote names
546    // and where a token would come from; optional either way.
547    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    // The repository's instructions for agents.
569    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/// `speccoder doctor`: the checks; the status of the first failure (ok
598/// when nothing fails).
599#[must_use]
600pub fn run(path: &Path, feature: Option<&str>, look: Option<&str>) -> CommandResult {
601    report(path, feature, look, None)
602}
603
604/// `speccoder doctor --agents`: the checks and every agent's card; with
605/// `probe`, each installed one is asked for a session (never a prompt).
606#[must_use]
607pub fn agents(path: &Path, feature: Option<&str>, probe: bool) -> CommandResult {
608    report(path, feature, None, Some(probe))
609}
610
611/// The configured `[executor]` agent, if it is an ACP one.
612fn 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
621/// The `executor` row: the configured agent's card, as known without
622/// starting it.
623fn 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
749/// The `worktree include` row: the git-ignored files a run's worktree gets
750/// copied from this checkout, by name; none when nothing names any.
751fn 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
783/// Untracked configuration a worktree will not have: speccoder works on
784/// commits, so a check that reads `.config/nextest.toml` or an untracked
785/// `*.toml` passes here and cannot run there.
786fn untracked_check(root: &Path, feature: Option<&str>) -> Option<DoctorCheck> {
787    let out = Git::at(root)
788        // NUL-separated: a name is never quoted or split.
789        .run(&[
790            "status",
791            "--porcelain",
792            "-z",
793            "--untracked-files=normal",
794            ".",
795        ])
796        .ok()?;
797    // Output and caches are no configuration: a worktree makes its own.
798    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    // The paths are the repository's: argvs, never a line read back.
841    // Literal pathspecs: a name like `*.json` or `:(top)x` is that file.
842    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        // What of it a run needs, speccoder commits itself.
857        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
868/// Which unit commands work on when none is named, and why — or what
869/// stops them from knowing (several units, a pointer to a gone one).
870fn 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/// Replaces values that look like secrets and the home directory.
909#[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/// The bug report of `doctor`'s `result` for the project at `root`:
945/// versions, every check, and the configuration — in Markdown, ready for
946/// an issue, with secrets and the home directory taken out.
947#[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        // `.worktreeinclude` too; a tracked file never.
1044        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
1078/// How to sign with SSH: with a public key the person has, the newest kind
1079/// first; without one, make one first. Never a key that does not exist.
1080fn 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}