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