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    }
27}
28
29fn failed(name: &str, code: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
30    DoctorCheck {
31        code: Some(code.to_owned()),
32        fix: fix.iter().map(|f| (*f).to_owned()).collect(),
33        ..check(name, DoctorLevel::Fail, detail)
34    }
35}
36
37fn warned(name: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
38    DoctorCheck {
39        fix: fix.iter().map(|f| (*f).to_owned()).collect(),
40        ..check(name, DoctorLevel::Warn, detail)
41    }
42}
43
44/// The plan checks as doctor rows: `check: "plan"`, level `warn`, the
45/// code's catalog entry, the tasks, the fixes and, for behaviour planned
46/// early, the decision speccoder records as the remedy.
47#[must_use]
48pub fn plan_checks(
49    adapter: &dyn crate::format::Adapter,
50    snapshot: &speccoder_core::snapshot::ProjectSnapshot,
51    root: &Path,
52) -> Vec<DoctorCheck> {
53    let feature = snapshot
54        .feature
55        .as_ref()
56        .map(|f| f.feature.name.clone())
57        .unwrap_or_default();
58    crate::plan_lint::lints(adapter, snapshot, root)
59        .into_iter()
60        .map(|l| DoctorCheck {
61            code: Some(l.code.to_owned()),
62            fix: l.fix.clone(),
63            tasks: l.tasks.clone(),
64            remedy: l
65                .acceptance_at
66                .as_ref()
67                .map(|(task, story)| speccoder_core::Remedy {
68                    label: format!("Write {story}'s acceptance at {task}"),
69                    effect: format!("{task}'s tick can be verified on {story}'s scenarios"),
70                    command: format!("speccoder task acceptance-at {task} --story {story}"),
71                    undo: Some(format!(
72                        "speccoder task acceptance-at {task} --story {story} --revoke"
73                    )),
74                    history: false,
75                    run: speccoder_core::actions::Runnable::AcceptanceAt {
76                        feature: feature.clone(),
77                        story: story.clone(),
78                        task: task.clone(),
79                        revoke: false,
80                    },
81                }),
82            ..check("plan", DoctorLevel::Warn, l.detail)
83        })
84        .collect()
85}
86
87/// Whether the speccoder preset is active for the tasks template.
88fn preset_check(root: &Path) -> DoctorCheck {
89    let state = crate::preset::state(root);
90    let offer = |update: bool| speccoder_core::Remedy {
91        label: if update {
92            "Update the speccoder preset".to_owned()
93        } else {
94            "Add the speccoder preset".to_owned()
95        },
96        effect: "future task lists plan for evidence; no spec file changes".to_owned(),
97        command: "speccoder preset add --yes".to_owned(),
98        undo: Some("specify preset remove speccoder".to_owned()),
99        history: false,
100        run: speccoder_core::actions::Runnable::PresetAdd { update },
101    };
102    match (&state.installed, state.active_for_tasks_template) {
103        (Some(v), true) if v == &state.bundled || v == "unknown" => check(
104            "preset",
105            DoctorLevel::Pass,
106            format!("speccoder preset {v} active for tasks-template"),
107        ),
108        (Some(v), active) => DoctorCheck {
109            remedy: Some(offer(true)),
110            fix: vec!["speccoder preset add --yes".to_owned()],
111            ..check(
112                "preset",
113                DoctorLevel::Info,
114                format!(
115                    "speccoder preset {v} installed{} — this speccoder bundles {}",
116                    if active {
117                        ""
118                    } else {
119                        ", not active for tasks-template"
120                    },
121                    state.bundled
122                ),
123            )
124        },
125        (None, _) => DoctorCheck {
126            remedy: Some(offer(false)),
127            fix: vec!["speccoder preset add --yes".to_owned()],
128            ..check(
129                "preset",
130                DoctorLevel::Info,
131                "speccoder preset not installed (tasks-template from Spec Kit's core)",
132            )
133        },
134    }
135}
136
137/// The first line a program prints for `--version`, if it runs.
138fn version(program: &str) -> Option<String> {
139    let out = Command::new(program).arg("--version").output().ok()?;
140    out.status.success().then(|| {
141        String::from_utf8_lossy(&out.stdout)
142            .lines()
143            .next()
144            .unwrap_or_default()
145            .trim()
146            .to_owned()
147    })
148}
149
150/// Bedrock's `model key` row: the region and where the AWS chain will take
151/// credentials from, named offline (never a value, never a call); and a
152/// `model region` warning when `AWS_REGION` names another region than
153/// the file, which wins.
154fn bedrock_key(
155    m: &crate::substrate::model_config::ModelConfig,
156    env: &dyn Fn(&str) -> Option<String>,
157) -> Vec<DoctorCheck> {
158    let set = |k: &str| env(k).filter(|v| !v.is_empty());
159    let home = std::path::PathBuf::from(set("HOME").unwrap_or_default());
160    let region = m.region.clone().unwrap_or_default();
161    let mut out = vec![match crate::aws::detect(&set, &home) {
162        Some(source) => {
163            let mut detail = format!("bedrock {} · {region} · {}", m.model, source.describe());
164            if source.kind == crate::aws::Kind::ApiKey
165                && let Some(p) = crate::aws::profile(&set, &home)
166            {
167                detail.push_str(&format!(
168                    " (the API key is used; profile {} is not)",
169                    p.name
170                ));
171            }
172            check("model key", DoctorLevel::Pass, detail)
173        }
174        None => DoctorCheck {
175            code: Some("KEY-MISSING".to_owned()),
176            fix: crate::substrate::model_config::bedrock_fixes(),
177            ..check(
178                "model key",
179                DoctorLevel::Fail,
180                format!(
181                    "{} is not set and no AWS profile was found",
182                    crate::aws::API_KEY
183                ),
184            )
185        },
186    }];
187    if let Some(other) = set("AWS_REGION").filter(|r| r.trim() != region) {
188        out.push(check(
189            "model region",
190            DoctorLevel::Warn,
191            format!("{region} in speccoder.toml, AWS_REGION is {other} — the file is used"),
192        ));
193    }
194    out
195}
196
197/// Every check, in order, for the project at `path`.
198#[must_use]
199pub fn checks(
200    path: &Path,
201    feature: Option<&str>,
202    env: &dyn Fn(&str) -> Option<String>,
203) -> Vec<DoctorCheck> {
204    let mut out = Vec::new();
205    // git and the repository.
206    let git = version("git");
207    out.push(match &git {
208        Some(v) => check("git", DoctorLevel::Pass, v.clone()),
209        None => failed(
210            "git",
211            "GIT",
212            "git is not installed or does not run",
213            &["→ install git"],
214        ),
215    });
216    let top = Git::at(path).toplevel().ok().flatten();
217    let has_commit = top.as_ref().is_some_and(|t| {
218        Git::at(t)
219            .line(&["rev-parse", "--verify", "-q", "HEAD"])
220            .is_ok()
221    });
222    out.push(match (&top, has_commit) {
223        (Some(t), true) => check("repository", DoctorLevel::Pass, t.display().to_string()),
224        (Some(_), false) => failed(
225            "repository",
226            "NO-REPOSITORY",
227            "the repository has no commit yet",
228            &["git add -A && git commit -m init"],
229        ),
230        (None, _) => failed(
231            "repository",
232            "NO-REPOSITORY",
233            "not a git repository",
234            &["git init && git add -A && git commit -m init"],
235        ),
236    });
237    // The spec project, and its own tool.
238    let opened = crate::format::open(path, feature);
239    let root = match &opened {
240        Ok(o) => {
241            let kind = o.adapter.wording().name;
242            let feature = o
243                .snapshot
244                .feature
245                .as_ref()
246                .map(|f| format!(", feature {}", f.feature.name))
247                .unwrap_or_default();
248            out.push(check(
249                "project",
250                DoctorLevel::Pass,
251                format!("{kind} at {}{feature}", o.snapshot.root),
252            ));
253            out.push(current_check(o.adapter, &o.snapshot));
254            let name = o.snapshot.feature.as_ref().map(|f| f.feature.name.as_str());
255            if let Some(c) = untracked_check(Path::new(&o.snapshot.root), name) {
256                out.push(c);
257            }
258            if let Some((tool, install)) = o.adapter.tool() {
259                out.push(match version(tool) {
260                    Some(v) => check("spec tool", DoctorLevel::Pass, v),
261                    None => DoctorCheck {
262                        fix: vec![format!("→ {install}")],
263                        ..check(
264                            "spec tool",
265                            DoctorLevel::Warn,
266                            format!(
267                                "`{tool}` is not on PATH: planning skills and cross-checks need it"
268                            ),
269                        )
270                    },
271                });
272            }
273            // The speccoder preset, as Spec Kit reports it.
274            if o.adapter.kind() == speccoder_core::spec::FormatKind::Speckit
275                && version("specify").is_some()
276            {
277                out.push(preset_check(Path::new(&o.snapshot.root)));
278            }
279            out.extend(o.adapter.doctor_checks(Path::new(&o.snapshot.root), env));
280            // The plan checks of the selected feature: warnings.
281            if o.snapshot.feature.is_some() {
282                out.extend(plan_checks(
283                    o.adapter,
284                    &o.snapshot,
285                    Path::new(&o.snapshot.root),
286                ));
287            }
288            // Where speccoder's acceptance tests would go.
289            let root = std::path::PathBuf::from(&o.snapshot.root);
290            let layout = crate::layout::detect(&root, &crate::verify::check_argvs(&root));
291            if let Some(r) = layout.primary().filter(|r| r.story_dir.is_none()) {
292                out.push(warned(
293                    "tests",
294                    format!(
295                        "no place the runner collects new test files, so no acceptance test is written: {}",
296                        r.unknown.join("; ")
297                    ),
298                    &["→ let the runner collect a tests directory (Cargo: drop `autotests = false`; pytest: a `testpaths` entry that exists)"],
299                ));
300            }
301            root
302        }
303        Err(r) => {
304            let fix = speccoder_core::codes::lookup("NO-PROJECT")
305                .map(|e| speccoder_core::codes::fixes(e, "doctor", None, None))
306                .unwrap_or_default();
307            out.push(DoctorCheck {
308                code: Some("NO-PROJECT".to_owned()),
309                fix,
310                ..check("project", DoctorLevel::Fail, r.message.clone())
311            });
312            path.to_path_buf()
313        }
314    };
315    // The configuration: present, valid, committed.
316    let config_dir = crate::verify::find_config(&root);
317    let text = config_dir
318        .as_ref()
319        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok());
320    let mut run_config = None;
321    let mut model = None;
322    match (&config_dir, &text) {
323        (Some(dir), Some(text)) => {
324            let parsed = crate::verify::config::parse(text)
325                .map(|_| ())
326                .and_then(|()| crate::substrate::model_config::parse(text).map(|m| model = m))
327                .and_then(|()| {
328                    crate::substrate::run_config::parse(text).map(|c| run_config = Some(c))
329                });
330            match parsed {
331                Err(e) => out.push(failed(
332                    "config",
333                    "CONFIG-INVALID",
334                    e,
335                    &["→ fix the line the message names in speccoder.toml"],
336                )),
337                Ok(()) => {
338                    let file = dir.join(crate::verify::config::FILE);
339                    let g = Git::at(dir);
340                    let tracked = g
341                        .succeeds(&[
342                            "ls-files",
343                            "--error-unmatch",
344                            "--",
345                            crate::verify::config::FILE,
346                        ])
347                        .unwrap_or(false);
348                    let clean = g
349                        .succeeds(&["diff", "--quiet", "HEAD", "--", crate::verify::config::FILE])
350                        .unwrap_or(false);
351                    out.push(if tracked && clean {
352                        check("config", DoctorLevel::Pass, file.display().to_string())
353                    } else {
354                        failed(
355                            "config",
356                            "CONFIG-NOT-TRACKED",
357                            format!("{} is not committed as it is: speccoder reads committed files only", file.display()),
358                            &["git add speccoder.toml && git commit -m \"speccoder: config\""],
359                        )
360                    });
361                }
362            }
363        }
364        _ => out.push(failed(
365            "config",
366            "CONFIG-MISSING",
367            "no speccoder.toml",
368            &["speccoder init"],
369        )),
370    }
371    // The model, then its key.
372    if let Some(m) = &model {
373        out.push(check(
374            "model",
375            DoctorLevel::Pass,
376            format!("{}/{}", m.provider, m.model),
377        ));
378        let variable = m.key_variable().to_owned();
379        if m.provider == "bedrock" {
380            out.extend(bedrock_key(m, env));
381        } else {
382            out.push(
383                if variable.is_empty() || env(&variable).is_some_and(|v| !v.is_empty()) {
384                    check(
385                        "model key",
386                        DoctorLevel::Pass,
387                        format!("{} {}", m.provider, m.model),
388                    )
389                } else {
390                    DoctorCheck {
391                        code: Some("KEY-MISSING".to_owned()),
392                        fix: vec![format!("export {variable}=…")],
393                        ..check(
394                            "model key",
395                            DoctorLevel::Fail,
396                            format!("{variable} is not set"),
397                        )
398                    }
399                },
400            );
401        }
402    }
403    // The sandbox.
404    let sandbox = text
405        .as_deref()
406        .and_then(|t| crate::sandbox::parse(t).ok())
407        .unwrap_or_default();
408    out.push(match (crate::sandbox::backend(), sandbox.mode) {
409        (Ok(b), _) => check(
410            "sandbox",
411            DoctorLevel::Pass,
412            format!("{b:?}").to_lowercase(),
413        ),
414        (Err(e), crate::sandbox::Mode::Required) => failed(
415            "sandbox",
416            "SANDBOX-UNAVAILABLE",
417            e,
418            &["→ run where the OS sandbox exists, or set [sandbox] mode = \"auto\""],
419        ),
420        (Err(e), crate::sandbox::Mode::Off) => {
421            check("sandbox", DoctorLevel::Warn, format!("off ({e})"))
422        }
423        (Err(e), crate::sandbox::Mode::Auto) => warned(
424            "sandbox",
425            format!("not available, commands run unsandboxed: {e}"),
426            &["→ prefer a container or VM without your credentials"],
427        ),
428    });
429    // `[worktree] include` and `.worktreeinclude`: what a run's worktree
430    // gets copied — names only, nothing copied.
431    let worktree = text
432        .as_deref()
433        .and_then(|t| crate::prepare::parse(t).ok())
434        .unwrap_or_default();
435    if let Some(dir) = crate::verify::find_config(path)
436        && let Some(row) = include_check(&dir, &worktree.include)
437    {
438        out.push(row);
439    }
440    // The state directory.
441    out.push(match crate::worktree::state_dir() {
442        Some(dir) => {
443            // Probed without creating it: a check writes nothing of its
444            // own — the directory, or the nearest folder it would be made
445            // in. The nearest path that exists must be a folder: a file
446            // there (the directory itself, or a parent) blocks creating it.
447            let existing = dir.ancestors().find(|a| a.exists());
448            let writable = existing
449                .filter(|a| a.is_dir())
450                .is_some_and(|a| tempfile::tempfile_in(a).is_ok());
451            if writable {
452                check("state", DoctorLevel::Pass, dir.display().to_string())
453            } else {
454                failed(
455                    "state",
456                    "NO-STATE-DIR",
457                    format!("{} is not writable", dir.display()),
458                    &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
459                )
460            }
461        }
462        None => failed(
463            "state",
464            "NO-STATE-DIR",
465            "no state directory (SPECCODER_STATE_DIR, XDG_STATE_HOME and HOME are unset)",
466            &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
467        ),
468    });
469    // A person's decisions: a file from before decisions named objects is
470    // refused by every run (never read as "no decisions").
471    if let Some(dir) = crate::worktree::state_dir() {
472        let project = crate::worktree::project_state_dir(&dir, &root);
473        if let Err(e) = crate::approvals::Decisions::load(&project) {
474            out.push(failed(
475                "decisions",
476                "CONFIG-INVALID",
477                e,
478                &[&format!(
479                    "rm {}   (it predates this version; its decisions are not carried over)",
480                    project.join(crate::approvals::FILE).display()
481                )],
482            ));
483        }
484    }
485    // The attestation signing key.
486    let attest = run_config
487        .as_ref()
488        .map(|c| c.attest.clone())
489        .unwrap_or_default();
490    if attest.enabled {
491        let repo = top.clone().unwrap_or_else(|| root.clone());
492        out.push(match crate::attest::sign::signer(&repo, &attest) {
493            Ok(s) => check(
494                "signing",
495                DoctorLevel::Pass,
496                format!("attestations signed with {}", s.kind()),
497            ),
498            Err(e) => {
499                let fix = signing_fix(
500                    std::env::var_os("HOME")
501                        .map(std::path::PathBuf::from)
502                        .as_deref(),
503                );
504                // Optional: every tick is attested either way; a key signs it.
505                DoctorCheck {
506                    fix,
507                    ..check(
508                        "signing",
509                        DoctorLevel::Info,
510                        format!("attestations are not signed (optional): {e}"),
511                    )
512                }
513            }
514        });
515    }
516    // GitHub (issues, pull requests): offline — the host the remote names
517    // and where a token would come from; optional either way.
518    if let Ok(r) = crate::github::repo(&root, None) {
519        let ctx = crate::github::Ctx::from_env();
520        out.push(match ctx.token(&r.host) {
521            Some((_, source)) => check(
522                "github",
523                DoctorLevel::Pass,
524                format!("{} on {} (token from {})", r.slug(), r.host, source.word()),
525            ),
526            None => DoctorCheck {
527                fix: vec!["speccoder auth login --with-token".to_owned()],
528                ..check(
529                    "github",
530                    DoctorLevel::Info,
531                    format!(
532                        "not signed in to {} (optional: for issues and pull requests)",
533                        r.host
534                    ),
535                )
536            },
537        });
538    }
539    // The repository's instructions for agents.
540    let names = run_config
541        .as_ref()
542        .map(|c| c.instructions.clone())
543        .unwrap_or_else(|| vec!["AGENTS.md".to_owned()]);
544    let repo = top.unwrap_or_else(|| root.clone());
545    let given = crate::instructions::collect(&repo, &root, &[], &names);
546    out.push(if !given.is_empty() {
547        check(
548            "instructions",
549            DoctorLevel::Pass,
550            given
551                .iter()
552                .map(|i| i.path.clone())
553                .collect::<Vec<_>>()
554                .join(", "),
555        )
556    } else if repo.join("CLAUDE.md").is_file() && !names.iter().any(|n| n == "CLAUDE.md") {
557        warned(
558            "instructions",
559            "CLAUDE.md is here but not read: attempts get no repository instructions",
560            &["→ add [context] instructions = [\"AGENTS.md\", \"CLAUDE.md\"] to speccoder.toml"],
561        )
562    } else {
563        check("instructions", DoctorLevel::Pass, "none (no AGENTS.md)")
564    });
565    out
566}
567
568/// `speccoder doctor`: the checks; the status of the first failure (ok
569/// when nothing fails).
570#[must_use]
571pub fn run(path: &Path, feature: Option<&str>, look: Option<&str>) -> CommandResult {
572    let mut checks = checks(path, feature, &|k| std::env::var(k).ok());
573    if let Some(look) = look {
574        checks.push(check("look", DoctorLevel::Pass, look));
575    }
576    let failures: Vec<&DoctorCheck> = checks
577        .iter()
578        .filter(|c| c.level == DoctorLevel::Fail)
579        .collect();
580    let warnings = checks
581        .iter()
582        .filter(|c| c.level == DoctorLevel::Warn)
583        .count();
584    let optional = checks
585        .iter()
586        .filter(|c| c.level == DoctorLevel::Info)
587        .count();
588    let report = DoctorReport {
589        speccoder: env!("CARGO_PKG_VERSION").to_owned(),
590        os: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
591        checks: checks.clone(),
592    };
593    let result = match failures.first() {
594        None => CommandResult::ok(
595            "doctor",
596            &format!(
597                "{} checks passed, {warnings} warning(s){}",
598                checks.len() - warnings - optional,
599                if optional > 0 {
600                    format!(", {optional} optional")
601                } else {
602                    String::new()
603                }
604            ),
605        ),
606        Some(first) => {
607            let entry = first
608                .code
609                .as_deref()
610                .and_then(speccoder_core::codes::lookup);
611            let status = entry.map_or(CommandStatus::Error, |e| e.status);
612            let message = format!(
613                "{} problem(s): {}",
614                failures.len(),
615                failures
616                    .iter()
617                    .map(|f| f.check.as_str())
618                    .collect::<Vec<_>>()
619                    .join(", ")
620            );
621            let mut r = CommandResult::new("doctor", status, &message).with_fix(first.fix.clone());
622            if let Some(code) = &first.code {
623                r = r.with_code(code);
624            }
625            r
626        }
627    };
628    result.with_data(CommandData::Doctor(Box::new(report)))
629}
630
631/// The `worktree include` row: the git-ignored files a run's worktree gets
632/// copied from this checkout, by name; none when nothing names any.
633fn include_check(project: &Path, patterns: &[String]) -> Option<DoctorCheck> {
634    if patterns.is_empty() && !project.join(crate::include::FILE).is_file() {
635        return None;
636    }
637    let files = crate::include::matched(project, patterns);
638    let protected = crate::tools::paths::Protected::default();
639    let masked = files.iter().filter(|f| protected.is_secret(f)).count();
640    Some(if files.is_empty() {
641        warned(
642            "worktree include",
643            "nothing to copy: no git-ignored file matches [worktree] include or .worktreeinclude",
644            &[],
645        )
646    } else {
647        check(
648            "worktree include",
649            DoctorLevel::Pass,
650            format!(
651                "copied into each run's worktree: {}{}",
652                files.join(", "),
653                if masked == 0 {
654                    String::new()
655                } else {
656                    format!(
657                        " ({masked} secret file(s): never committed, secret-named values masked)"
658                    )
659                }
660            ),
661        )
662    })
663}
664
665/// Untracked configuration a worktree will not have: speccoder works on
666/// commits, so a check that reads `.config/nextest.toml` or an untracked
667/// `*.toml` passes here and cannot run there.
668fn untracked_check(root: &Path, feature: Option<&str>) -> Option<DoctorCheck> {
669    let out = Git::at(root)
670        .run(&["status", "--porcelain", "--untracked-files=normal", "."])
671        .ok()?;
672    // Output and caches are no configuration: a worktree makes its own.
673    const OUTPUT: &[&str] = &[
674        ".DS_Store",
675        ".speccoder",
676        ".venv",
677        "venv",
678        "node_modules",
679        "target",
680        "__pycache__",
681        ".pytest_cache",
682        ".mypy_cache",
683        ".ruff_cache",
684        ".tox",
685        ".nox",
686        ".coverage",
687        "dist",
688        "build",
689        ".next",
690        ".turbo",
691        ".gradle",
692        ".idea",
693        ".vscode",
694    ];
695    let config = |p: &str| {
696        let name = p.trim_end_matches('/').rsplit('/').next().unwrap_or(p);
697        if OUTPUT.contains(&name) {
698            return false;
699        }
700        name.starts_with('.')
701            || [".toml", ".json", ".yaml", ".yml", ".cfg", ".ini"]
702                .iter()
703                .any(|e| name.ends_with(e))
704    };
705    let found: Vec<String> = String::from_utf8_lossy(&out)
706        .lines()
707        .filter_map(|l| l.strip_prefix("?? "))
708        .filter(|p| config(p))
709        .map(str::to_owned)
710        .collect();
711    if found.is_empty() {
712        return None;
713    }
714    let list = found.join(" ");
715    Some(DoctorCheck {
716        fix: vec![format!(
717            "git add {list} && git commit -m \"Track project config\"   (if the checks need them)"
718        )],
719        // What of it a run needs, speccoder commits itself.
720        remedy: crate::code::setup_remedy(root, feature),
721        ..check(
722            "untracked",
723            DoctorLevel::Warn,
724            format!(
725                "{list} — not committed, so a worktree does not have it; a check that needs it cannot run there"
726            ),
727        )
728    })
729}
730
731/// Which unit commands work on when none is named, and why — or what
732/// stops them from knowing (several units, a pointer to a gone one).
733fn current_check(
734    format: &'static dyn crate::format::Adapter,
735    snap: &speccoder_core::snapshot::ProjectSnapshot,
736) -> DoctorCheck {
737    let unit = format.messages().unit;
738    let problem = snap
739        .diagnostics
740        .iter()
741        .find(|d| d.code == "select.ambiguous" || d.code == "select.not-found");
742    match (&snap.feature, problem) {
743        (Some(f), _) => check(
744            "feature",
745            DoctorLevel::Pass,
746            format!(
747                "{} ({})",
748                f.feature.name,
749                crate::current::reason_text(snap.selection.reason, format)
750            ),
751        ),
752        (None, Some(d)) => DoctorCheck {
753            fix: vec!["→ speccoder feature".to_owned()]
754                .into_iter()
755                .chain(d.hint.iter().map(|h| format!("→ {h}")))
756                .collect(),
757            ..check("feature", DoctorLevel::Warn, d.message.clone())
758        },
759        (None, None) if snap.features.is_empty() => check(
760            "feature",
761            DoctorLevel::Pass,
762            format!("no {unit}s yet — `speccoder spec specify \"…\"` creates one"),
763        ),
764        (None, None) => DoctorCheck {
765            fix: vec!["→ speccoder feature".to_owned()],
766            ..check("feature", DoctorLevel::Warn, format!("no current {unit}"))
767        },
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    #[test]
776    fn the_files_a_worktree_gets_copied_are_named_and_nothing_is_copied() {
777        let dir = tempfile::tempdir().unwrap();
778        let r = dir.path();
779        let git = |args: &[&str]| {
780            assert!(
781                Command::new("git")
782                    .args([
783                        "-c",
784                        "user.name=t",
785                        "-c",
786                        "user.email=t@e",
787                        "-c",
788                        "commit.gpgsign=false"
789                    ])
790                    .args(args)
791                    .current_dir(r)
792                    .status()
793                    .unwrap()
794                    .success()
795            );
796        };
797        std::fs::write(r.join(".gitignore"), ".env\n*.local\n").unwrap();
798        std::fs::write(r.join("app.local"), "x").unwrap();
799        std::fs::write(r.join("tracked.local"), "x").unwrap();
800        git(&["init", "-q"]);
801        git(&["add", "-A"]);
802        git(&["add", "-f", "tracked.local"]);
803        git(&["commit", "-qm", "init"]);
804        std::fs::write(r.join(".env"), "TOKEN=abcdefgh123\n").unwrap();
805        assert!(include_check(r, &[]).is_none());
806        let row = include_check(r, &[".env".to_owned()]).unwrap();
807        assert_eq!(row.level, DoctorLevel::Pass);
808        assert_eq!(
809            row.detail,
810            "copied into each run's worktree: .env (1 secret file(s): never committed, secret-named values masked)"
811        );
812        assert!(!row.detail.contains("abcdefgh123"));
813        // `.worktreeinclude` too; a tracked file never.
814        std::fs::write(r.join(".worktreeinclude"), "*.local\n").unwrap();
815        let row = include_check(r, &[]).unwrap();
816        assert_eq!(row.detail, "copied into each run's worktree: app.local");
817        let row = include_check(r, &["nothing-here".to_owned()]).unwrap();
818        assert_eq!(row.detail, "copied into each run's worktree: app.local");
819    }
820
821    #[test]
822    fn outside_any_repository_every_problem_is_named_with_its_fix() {
823        let dir = tempfile::tempdir().unwrap();
824        let checks = checks(dir.path(), None, &|_| None);
825        let failing: Vec<(&str, Option<&str>)> = checks
826            .iter()
827            .filter(|c| c.level == DoctorLevel::Fail)
828            .map(|c| (c.check.as_str(), c.code.as_deref()))
829            .collect();
830        assert!(
831            failing.contains(&("repository", Some("NO-REPOSITORY"))),
832            "{failing:?}"
833        );
834        assert!(
835            failing.contains(&("project", Some("NO-PROJECT"))),
836            "{failing:?}"
837        );
838        assert!(
839            failing.contains(&("config", Some("CONFIG-MISSING"))),
840            "{failing:?}"
841        );
842        for c in checks.iter().filter(|c| c.level != DoctorLevel::Pass) {
843            assert!(!c.fix.is_empty(), "{} has no fix", c.check);
844        }
845    }
846}
847
848/// How to sign with SSH: with a public key the person has, the newest kind
849/// first; without one, make one first. Never a key that does not exist.
850fn signing_fix(home: Option<&Path>) -> Vec<String> {
851    let found = home.and_then(|h| {
852        [
853            "id_ed25519.pub",
854            "id_ecdsa.pub",
855            "id_ed25519_sk.pub",
856            "id_ecdsa_sk.pub",
857            "id_rsa.pub",
858        ]
859        .iter()
860        .find(|k| h.join(".ssh").join(k).is_file())
861    });
862    let mut fix = Vec::new();
863    let key = match found {
864        Some(k) => format!("~/.ssh/{k}"),
865        None => {
866            fix.push("ssh-keygen -t ed25519".to_owned());
867            "~/.ssh/id_ed25519.pub".to_owned()
868        }
869    };
870    fix.push("git config --global gpg.format ssh".to_owned());
871    fix.push(format!("git config --global user.signingKey {key}"));
872    fix
873}
874
875#[cfg(test)]
876mod signing_tests {
877    use super::*;
878
879    #[test]
880    fn the_signing_fix_names_a_key_that_exists() {
881        let home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
882        let none = signing_fix(Some(home.path()));
883        assert_eq!(none[0], "ssh-keygen -t ed25519");
884        std::fs::create_dir_all(home.path().join(".ssh")).unwrap_or_else(|e| panic!("{e}"));
885        std::fs::write(home.path().join(".ssh/id_rsa.pub"), "ssh-rsa AAAA")
886            .unwrap_or_else(|e| panic!("{e}"));
887        let rsa = signing_fix(Some(home.path()));
888        assert_eq!(
889            rsa.last().map(String::as_str),
890            Some("git config --global user.signingKey ~/.ssh/id_rsa.pub")
891        );
892        assert!(!rsa.iter().any(|f| f.starts_with("ssh-keygen")));
893    }
894}