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