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    }
26}
27
28fn failed(name: &str, code: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
29    DoctorCheck {
30        code: Some(code.to_owned()),
31        fix: fix.iter().map(|f| (*f).to_owned()).collect(),
32        ..check(name, DoctorLevel::Fail, detail)
33    }
34}
35
36fn warned(name: &str, detail: impl Into<String>, fix: &[&str]) -> DoctorCheck {
37    DoctorCheck {
38        fix: fix.iter().map(|f| (*f).to_owned()).collect(),
39        ..check(name, DoctorLevel::Warn, detail)
40    }
41}
42
43/// The first line a program prints for `--version`, if it runs.
44fn version(program: &str) -> Option<String> {
45    let out = Command::new(program).arg("--version").output().ok()?;
46    out.status.success().then(|| {
47        String::from_utf8_lossy(&out.stdout)
48            .lines()
49            .next()
50            .unwrap_or_default()
51            .trim()
52            .to_owned()
53    })
54}
55
56/// Every check, in order, for the project at `path`.
57#[must_use]
58pub fn checks(
59    path: &Path,
60    feature: Option<&str>,
61    env: &dyn Fn(&str) -> Option<String>,
62) -> Vec<DoctorCheck> {
63    let mut out = Vec::new();
64    // git and the repository.
65    let git = version("git");
66    out.push(match &git {
67        Some(v) => check("git", DoctorLevel::Pass, v.clone()),
68        None => failed(
69            "git",
70            "GIT",
71            "git is not installed or does not run",
72            &["→ install git"],
73        ),
74    });
75    let top = Git::at(path).toplevel().ok().flatten();
76    let has_commit = top.as_ref().is_some_and(|t| {
77        Git::at(t)
78            .line(&["rev-parse", "--verify", "-q", "HEAD"])
79            .is_ok()
80    });
81    out.push(match (&top, has_commit) {
82        (Some(t), true) => check("repository", DoctorLevel::Pass, t.display().to_string()),
83        (Some(_), false) => failed(
84            "repository",
85            "NO-REPOSITORY",
86            "the repository has no commit yet",
87            &["git add -A && git commit -m init"],
88        ),
89        (None, _) => failed(
90            "repository",
91            "NO-REPOSITORY",
92            "not a git repository",
93            &["git init && git add -A && git commit -m init"],
94        ),
95    });
96    // The spec project, and its own tool.
97    let opened = crate::format::open(path, feature);
98    let root = match &opened {
99        Ok(o) => {
100            let kind = o.adapter.wording().name;
101            let feature = o
102                .snapshot
103                .feature
104                .as_ref()
105                .map(|f| format!(", feature {}", f.feature.name))
106                .unwrap_or_default();
107            out.push(check(
108                "project",
109                DoctorLevel::Pass,
110                format!("{kind} at {}{feature}", o.snapshot.root),
111            ));
112            out.push(current_check(o.adapter, &o.snapshot));
113            let name = o.snapshot.feature.as_ref().map(|f| f.feature.name.as_str());
114            if let Some(c) = untracked_check(Path::new(&o.snapshot.root), name) {
115                out.push(c);
116            }
117            if let Some((tool, install)) = o.adapter.tool() {
118                out.push(match version(tool) {
119                    Some(v) => check("spec tool", DoctorLevel::Pass, v),
120                    None => DoctorCheck {
121                        fix: vec![format!("→ {install}")],
122                        ..check(
123                            "spec tool",
124                            DoctorLevel::Warn,
125                            format!(
126                                "`{tool}` is not on PATH: planning skills and cross-checks need it"
127                            ),
128                        )
129                    },
130                });
131            }
132            std::path::PathBuf::from(&o.snapshot.root)
133        }
134        Err(r) => {
135            let fix = speccoder_core::codes::lookup("NO-PROJECT")
136                .map(|e| speccoder_core::codes::fixes(e, "doctor", None, None))
137                .unwrap_or_default();
138            out.push(DoctorCheck {
139                code: Some("NO-PROJECT".to_owned()),
140                fix,
141                ..check("project", DoctorLevel::Fail, r.message.clone())
142            });
143            path.to_path_buf()
144        }
145    };
146    // The configuration: present, valid, committed.
147    let config_dir = crate::verify::find_config(&root);
148    let text = config_dir
149        .as_ref()
150        .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok());
151    let mut run_config = None;
152    let mut model = None;
153    match (&config_dir, &text) {
154        (Some(dir), Some(text)) => {
155            let parsed = crate::verify::config::parse(text)
156                .map(|_| ())
157                .and_then(|()| crate::substrate::model_config::parse(text).map(|m| model = m))
158                .and_then(|()| {
159                    crate::substrate::run_config::parse(text).map(|c| run_config = Some(c))
160                });
161            match parsed {
162                Err(e) => out.push(failed(
163                    "config",
164                    "CONFIG-INVALID",
165                    e,
166                    &["→ fix the line the message names in speccoder.toml"],
167                )),
168                Ok(()) => {
169                    let file = dir.join(crate::verify::config::FILE);
170                    let g = Git::at(dir);
171                    let tracked = g
172                        .succeeds(&[
173                            "ls-files",
174                            "--error-unmatch",
175                            "--",
176                            crate::verify::config::FILE,
177                        ])
178                        .unwrap_or(false);
179                    let clean = g
180                        .succeeds(&["diff", "--quiet", "HEAD", "--", crate::verify::config::FILE])
181                        .unwrap_or(false);
182                    out.push(if tracked && clean {
183                        check("config", DoctorLevel::Pass, file.display().to_string())
184                    } else {
185                        failed(
186                            "config",
187                            "CONFIG-NOT-TRACKED",
188                            format!("{} is not committed as it is: speccoder reads committed files only", file.display()),
189                            &["git add speccoder.toml && git commit -m \"speccoder: config\""],
190                        )
191                    });
192                }
193            }
194        }
195        _ => out.push(failed(
196            "config",
197            "CONFIG-MISSING",
198            "no speccoder.toml",
199            &["speccoder init"],
200        )),
201    }
202    // The model, then its key.
203    if let Some(m) = &model {
204        out.push(check(
205            "model",
206            DoctorLevel::Pass,
207            format!("{}/{}", m.provider, m.model),
208        ));
209        let variable = m.key_variable().to_owned();
210        out.push(
211            if variable.is_empty() || env(&variable).is_some_and(|v| !v.is_empty()) {
212                check(
213                    "model key",
214                    DoctorLevel::Pass,
215                    format!("{} {}", m.provider, m.model),
216                )
217            } else {
218                DoctorCheck {
219                    code: Some("KEY-MISSING".to_owned()),
220                    fix: vec![format!("export {variable}=…")],
221                    ..check(
222                        "model key",
223                        DoctorLevel::Fail,
224                        format!("{variable} is not set"),
225                    )
226                }
227            },
228        );
229    }
230    // The sandbox.
231    let sandbox = text
232        .as_deref()
233        .and_then(|t| crate::sandbox::parse(t).ok())
234        .unwrap_or_default();
235    out.push(match (crate::sandbox::backend(), sandbox.mode) {
236        (Ok(b), _) => check(
237            "sandbox",
238            DoctorLevel::Pass,
239            format!("{b:?}").to_lowercase(),
240        ),
241        (Err(e), crate::sandbox::Mode::Required) => failed(
242            "sandbox",
243            "SANDBOX-UNAVAILABLE",
244            e,
245            &["→ run where the OS sandbox exists, or set [sandbox] mode = \"auto\""],
246        ),
247        (Err(e), crate::sandbox::Mode::Off) => {
248            check("sandbox", DoctorLevel::Warn, format!("off ({e})"))
249        }
250        (Err(e), crate::sandbox::Mode::Auto) => warned(
251            "sandbox",
252            format!("not available, commands run unsandboxed: {e}"),
253            &["→ prefer a container or VM without your credentials"],
254        ),
255    });
256    // The state directory.
257    out.push(match crate::worktree::state_dir() {
258        Some(dir) => {
259            let writable =
260                std::fs::create_dir_all(&dir).is_ok() && tempfile::tempfile_in(&dir).is_ok();
261            if writable {
262                check("state", DoctorLevel::Pass, dir.display().to_string())
263            } else {
264                failed(
265                    "state",
266                    "NO-STATE-DIR",
267                    format!("{} is not writable", dir.display()),
268                    &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
269                )
270            }
271        }
272        None => failed(
273            "state",
274            "NO-STATE-DIR",
275            "no state directory (SPECCODER_STATE_DIR, XDG_STATE_HOME and HOME are unset)",
276            &["export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""],
277        ),
278    });
279    // A person's decisions: a file from before decisions named objects is
280    // refused by every run (never read as "no decisions").
281    if let Some(dir) = crate::worktree::state_dir() {
282        let project = crate::worktree::project_state_dir(&dir, &root);
283        if let Err(e) = crate::approvals::Decisions::load(&project) {
284            out.push(failed(
285                "decisions",
286                "CONFIG-INVALID",
287                e,
288                &[&format!(
289                    "rm {}   (it predates this version; its decisions are not carried over)",
290                    project.join(crate::approvals::FILE).display()
291                )],
292            ));
293        }
294    }
295    // The attestation signing key.
296    let attest = run_config
297        .as_ref()
298        .map(|c| c.attest.clone())
299        .unwrap_or_default();
300    if attest.enabled {
301        let repo = top.clone().unwrap_or_else(|| root.clone());
302        out.push(match crate::attest::sign::signer(&repo, &attest) {
303            Ok(s) => check(
304                "signing",
305                DoctorLevel::Pass,
306                format!("attestations signed with {}", s.kind()),
307            ),
308            Err(e) => {
309                let fix = signing_fix(
310                    std::env::var_os("HOME")
311                        .map(std::path::PathBuf::from)
312                        .as_deref(),
313                );
314                // Optional: every tick is attested either way; a key signs it.
315                DoctorCheck {
316                    fix,
317                    ..check(
318                        "signing",
319                        DoctorLevel::Info,
320                        format!("attestations are not signed (optional): {e}"),
321                    )
322                }
323            }
324        });
325    }
326    // GitHub (issues, pull requests): offline — the host the remote names
327    // and where a token would come from; optional either way.
328    if let Ok(r) = crate::github::repo(&root, None) {
329        let ctx = crate::github::Ctx::from_env();
330        out.push(match ctx.token(&r.host) {
331            Some((_, source)) => check(
332                "github",
333                DoctorLevel::Pass,
334                format!("{} on {} (token from {})", r.slug(), r.host, source.word()),
335            ),
336            None => DoctorCheck {
337                fix: vec!["speccoder auth login --with-token".to_owned()],
338                ..check(
339                    "github",
340                    DoctorLevel::Info,
341                    format!(
342                        "not signed in to {} (optional: for issues and pull requests)",
343                        r.host
344                    ),
345                )
346            },
347        });
348    }
349    // The repository's instructions for agents.
350    let names = run_config
351        .as_ref()
352        .map(|c| c.instructions.clone())
353        .unwrap_or_else(|| vec!["AGENTS.md".to_owned()]);
354    let repo = top.unwrap_or_else(|| root.clone());
355    let given = crate::instructions::collect(&repo, &root, &[], &names);
356    out.push(if !given.is_empty() {
357        check(
358            "instructions",
359            DoctorLevel::Pass,
360            given
361                .iter()
362                .map(|i| i.path.clone())
363                .collect::<Vec<_>>()
364                .join(", "),
365        )
366    } else if repo.join("CLAUDE.md").is_file() && !names.iter().any(|n| n == "CLAUDE.md") {
367        warned(
368            "instructions",
369            "CLAUDE.md is here but not read: attempts get no repository instructions",
370            &["→ add [context] instructions = [\"AGENTS.md\", \"CLAUDE.md\"] to speccoder.toml"],
371        )
372    } else {
373        check("instructions", DoctorLevel::Pass, "none (no AGENTS.md)")
374    });
375    out
376}
377
378/// `speccoder doctor`: the checks; the status of the first failure (ok
379/// when nothing fails).
380#[must_use]
381pub fn run(path: &Path, feature: Option<&str>, look: Option<&str>) -> CommandResult {
382    let mut checks = checks(path, feature, &|k| std::env::var(k).ok());
383    if let Some(look) = look {
384        checks.push(check("look", DoctorLevel::Pass, look));
385    }
386    let failures: Vec<&DoctorCheck> = checks
387        .iter()
388        .filter(|c| c.level == DoctorLevel::Fail)
389        .collect();
390    let warnings = checks
391        .iter()
392        .filter(|c| c.level == DoctorLevel::Warn)
393        .count();
394    let optional = checks
395        .iter()
396        .filter(|c| c.level == DoctorLevel::Info)
397        .count();
398    let report = DoctorReport {
399        speccoder: env!("CARGO_PKG_VERSION").to_owned(),
400        os: format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
401        checks: checks.clone(),
402    };
403    let result = match failures.first() {
404        None => CommandResult::ok(
405            "doctor",
406            &format!(
407                "{} checks passed, {warnings} warning(s){}",
408                checks.len() - warnings - optional,
409                if optional > 0 {
410                    format!(", {optional} optional")
411                } else {
412                    String::new()
413                }
414            ),
415        ),
416        Some(first) => {
417            let entry = first
418                .code
419                .as_deref()
420                .and_then(speccoder_core::codes::lookup);
421            let status = entry.map_or(CommandStatus::Error, |e| e.status);
422            let message = format!(
423                "{} problem(s): {}",
424                failures.len(),
425                failures
426                    .iter()
427                    .map(|f| f.check.as_str())
428                    .collect::<Vec<_>>()
429                    .join(", ")
430            );
431            let mut r = CommandResult::new("doctor", status, &message).with_fix(first.fix.clone());
432            if let Some(code) = &first.code {
433                r = r.with_code(code);
434            }
435            r
436        }
437    };
438    result.with_data(CommandData::Doctor(Box::new(report)))
439}
440
441/// Untracked configuration a worktree will not have: speccoder works on
442/// commits, so a check that reads `.config/nextest.toml` or an untracked
443/// `*.toml` passes here and cannot run there.
444fn untracked_check(root: &Path, feature: Option<&str>) -> Option<DoctorCheck> {
445    let out = Git::at(root)
446        .run(&["status", "--porcelain", "--untracked-files=normal", "."])
447        .ok()?;
448    // Output and caches are no configuration: a worktree makes its own.
449    const OUTPUT: &[&str] = &[
450        ".DS_Store",
451        ".speccoder",
452        ".venv",
453        "venv",
454        "node_modules",
455        "target",
456        "__pycache__",
457        ".pytest_cache",
458        ".mypy_cache",
459        ".ruff_cache",
460        ".tox",
461        ".nox",
462        ".coverage",
463        "dist",
464        "build",
465        ".next",
466        ".turbo",
467        ".gradle",
468        ".idea",
469        ".vscode",
470    ];
471    let config = |p: &str| {
472        let name = p.trim_end_matches('/').rsplit('/').next().unwrap_or(p);
473        if OUTPUT.contains(&name) {
474            return false;
475        }
476        name.starts_with('.')
477            || [".toml", ".json", ".yaml", ".yml", ".cfg", ".ini"]
478                .iter()
479                .any(|e| name.ends_with(e))
480    };
481    let found: Vec<String> = String::from_utf8_lossy(&out)
482        .lines()
483        .filter_map(|l| l.strip_prefix("?? "))
484        .filter(|p| config(p))
485        .map(str::to_owned)
486        .collect();
487    if found.is_empty() {
488        return None;
489    }
490    let list = found.join(" ");
491    Some(DoctorCheck {
492        fix: vec![format!(
493            "git add {list} && git commit -m \"Track project config\"   (if the checks need them)"
494        )],
495        // What of it a run needs, speccoder commits itself.
496        remedy: crate::code::setup_remedy(root, feature),
497        ..check(
498            "untracked",
499            DoctorLevel::Warn,
500            format!(
501                "{list} — not committed, so a worktree does not have it; a check that needs it cannot run there"
502            ),
503        )
504    })
505}
506
507/// Which unit commands work on when none is named, and why — or what
508/// stops them from knowing (several units, a pointer to a gone one).
509fn current_check(
510    format: &'static dyn crate::format::Adapter,
511    snap: &speccoder_core::snapshot::ProjectSnapshot,
512) -> DoctorCheck {
513    let unit = format.messages().unit;
514    let problem = snap
515        .diagnostics
516        .iter()
517        .find(|d| d.code == "select.ambiguous" || d.code == "select.not-found");
518    match (&snap.feature, problem) {
519        (Some(f), _) => check(
520            "feature",
521            DoctorLevel::Pass,
522            format!(
523                "{} ({})",
524                f.feature.name,
525                crate::current::reason_text(snap.selection.reason, format)
526            ),
527        ),
528        (None, Some(d)) => DoctorCheck {
529            fix: vec!["→ speccoder feature".to_owned()]
530                .into_iter()
531                .chain(d.hint.iter().map(|h| format!("→ {h}")))
532                .collect(),
533            ..check("feature", DoctorLevel::Warn, d.message.clone())
534        },
535        (None, None) if snap.features.is_empty() => check(
536            "feature",
537            DoctorLevel::Pass,
538            format!("no {unit}s yet — `speccoder spec specify \"…\"` creates one"),
539        ),
540        (None, None) => DoctorCheck {
541            fix: vec!["→ speccoder feature".to_owned()],
542            ..check("feature", DoctorLevel::Warn, format!("no current {unit}"))
543        },
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    #[test]
552    fn outside_any_repository_every_problem_is_named_with_its_fix() {
553        let dir = tempfile::tempdir().unwrap();
554        let checks = checks(dir.path(), None, &|_| None);
555        let failing: Vec<(&str, Option<&str>)> = checks
556            .iter()
557            .filter(|c| c.level == DoctorLevel::Fail)
558            .map(|c| (c.check.as_str(), c.code.as_deref()))
559            .collect();
560        assert!(
561            failing.contains(&("repository", Some("NO-REPOSITORY"))),
562            "{failing:?}"
563        );
564        assert!(
565            failing.contains(&("project", Some("NO-PROJECT"))),
566            "{failing:?}"
567        );
568        assert!(
569            failing.contains(&("config", Some("CONFIG-MISSING"))),
570            "{failing:?}"
571        );
572        for c in checks.iter().filter(|c| c.level != DoctorLevel::Pass) {
573            assert!(!c.fix.is_empty(), "{} has no fix", c.check);
574        }
575    }
576}
577
578/// How to sign with SSH: with a public key the person has, the newest kind
579/// first; without one, make one first. Never a key that does not exist.
580fn signing_fix(home: Option<&Path>) -> Vec<String> {
581    let found = home.and_then(|h| {
582        [
583            "id_ed25519.pub",
584            "id_ecdsa.pub",
585            "id_ed25519_sk.pub",
586            "id_ecdsa_sk.pub",
587            "id_rsa.pub",
588        ]
589        .iter()
590        .find(|k| h.join(".ssh").join(k).is_file())
591    });
592    let mut fix = Vec::new();
593    let key = match found {
594        Some(k) => format!("~/.ssh/{k}"),
595        None => {
596            fix.push("ssh-keygen -t ed25519".to_owned());
597            "~/.ssh/id_ed25519.pub".to_owned()
598        }
599    };
600    fix.push("git config --global gpg.format ssh".to_owned());
601    fix.push(format!("git config --global user.signingKey {key}"));
602    fix
603}
604
605#[cfg(test)]
606mod signing_tests {
607    use super::*;
608
609    #[test]
610    fn the_signing_fix_names_a_key_that_exists() {
611        let home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
612        let none = signing_fix(Some(home.path()));
613        assert_eq!(none[0], "ssh-keygen -t ed25519");
614        std::fs::create_dir_all(home.path().join(".ssh")).unwrap_or_else(|e| panic!("{e}"));
615        std::fs::write(home.path().join(".ssh/id_rsa.pub"), "ssh-rsa AAAA")
616            .unwrap_or_else(|e| panic!("{e}"));
617        let rsa = signing_fix(Some(home.path()));
618        assert_eq!(
619            rsa.last().map(String::as_str),
620            Some("git config --global user.signingKey ~/.ssh/id_rsa.pub")
621        );
622        assert!(!rsa.iter().any(|f| f.starts_with("ssh-keygen")));
623    }
624}