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