Skip to main content

speccoder_runtime/
init.rs

1//! `speccoder init`: detect a project's checks and write
2//! `speccoder.toml`.
3//!
4//! Detection looks only at the project root's manifests and lockfiles (and
5//! whether `cargo nextest` is installed); it runs nothing of the project's.
6//! What it finds becomes a commented file the person reviews and commits.
7//! The result is parsed back with speccoder's own parsers before it is
8//! written, so `init` never leaves a file `run` would refuse.
9
10use std::path::{Path, PathBuf};
11
12use serde::Serialize;
13use speccoder_core::{CommandData, CommandResult, CommandStatus};
14
15use crate::git::Git;
16use crate::verify::config::FILE;
17
18/// What `init` saw at the project root.
19#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct Found {
21    /// `Cargo.toml` exists.
22    pub cargo: bool,
23    /// `cargo nextest` is installed.
24    pub nextest: bool,
25    /// `package.json`'s scripts, by name.
26    pub npm_scripts: Vec<String>,
27    /// `npm`, `pnpm`, `yarn` or `bun`, from the lockfile; `None` without `package.json`.
28    pub node_manager: Option<String>,
29    /// `pyproject.toml`, `setup.cfg`, `setup.py` or `pytest.ini` exists.
30    pub python: bool,
31    /// The project's own interpreter (`.venv/bin/python`), when it has one:
32    /// checks use it, so they work without an activated environment.
33    pub python_bin: Option<String>,
34    /// pytest runs with that interpreter (or `python3`).
35    pub pytest: bool,
36    /// `[tool.ruff]` (or `ruff.toml`) is configured.
37    pub ruff: bool,
38    /// `[tool.mypy]` (or `mypy.ini`) is configured.
39    pub mypy: bool,
40    /// `go.mod` exists.
41    pub go: bool,
42    /// A `Makefile` with a `test:` target exists.
43    pub make_test: bool,
44}
45
46/// One check `init` proposes.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct Proposed {
49    /// `[checks.<name>]`.
50    pub name: String,
51    /// The command.
52    pub argv: Vec<String>,
53    /// `fast` or `full`.
54    pub tier: String,
55    /// A JUnit report the check writes.
56    pub junit: Option<String>,
57    /// Why this check.
58    pub why: String,
59}
60
61fn check(name: &str, argv: &[&str], tier: &str, junit: Option<&str>, why: &str) -> Proposed {
62    Proposed {
63        name: name.to_owned(),
64        argv: argv.iter().map(|a| (*a).to_owned()).collect(),
65        tier: tier.to_owned(),
66        junit: junit.map(str::to_owned),
67        why: why.to_owned(),
68    }
69}
70
71/// The checks for what was found (pure: no IO).
72#[must_use]
73pub fn propose(found: &Found) -> Vec<Proposed> {
74    let mut out = Vec::new();
75    if found.cargo {
76        out.push(if found.nextest {
77            check(
78                "test",
79                // A new project has no tests yet: warn, do not fail the baseline.
80                &[
81                    "cargo",
82                    "nextest",
83                    "run",
84                    "--workspace",
85                    "--profile",
86                    "speccoder",
87                    "--no-tests=warn",
88                ],
89                "fast",
90                Some("target/nextest/speccoder/junit.xml"),
91                "Cargo.toml; cargo-nextest is installed (its JUnit report names every test)",
92            )
93        } else {
94            check(
95                "test",
96                &["cargo", "test", "--workspace"],
97                "fast",
98                None,
99                "Cargo.toml (install cargo-nextest for per-test results)",
100            )
101        });
102        out.push(check(
103            "fmt",
104            &["cargo", "fmt", "--all", "--check"],
105            "fast",
106            None,
107            "Cargo.toml",
108        ));
109        out.push(check(
110            "clippy",
111            &[
112                "cargo",
113                "clippy",
114                "--workspace",
115                "--all-targets",
116                "--",
117                "-D",
118                "warnings",
119            ],
120            "full",
121            None,
122            "Cargo.toml",
123        ));
124    }
125    if let Some(pm) = &found.node_manager {
126        let has = |s: &str| found.npm_scripts.iter().any(|x| x == s);
127        // `<pm> run <script>`: the allowed prefix is that one script.
128        let run = |script: &str| -> Vec<String> { vec![pm.clone(), "run".into(), script.into()] };
129        let mut add = |name: &str, script: &str, tier: &str| {
130            out.push(Proposed {
131                name: name.to_owned(),
132                argv: run(script),
133                tier: tier.to_owned(),
134                junit: None,
135                why: format!("package.json script `{script}` ({pm})"),
136            });
137        };
138        if has("test") {
139            add(
140                if found.cargo { "node-test" } else { "test" },
141                "test",
142                "fast",
143            );
144        }
145        if has("typecheck") {
146            add("typecheck", "typecheck", "fast");
147        }
148        if has("lint") {
149            add("lint", "lint", "full");
150        }
151    }
152    if found.python {
153        let name = if found.cargo || found.node_manager.is_some() {
154            "pytest"
155        } else {
156            "test"
157        };
158        let python = found.python_bin.as_deref().unwrap_or("python3");
159        let why = if found.pytest {
160            format!("a Python project (pytest, {python})")
161        } else {
162            format!(
163                "a Python project — pytest is not installed for {python}: `{python} -m pip install pytest`"
164            )
165        };
166        out.push(check(
167            name,
168            &[
169                python,
170                "-m",
171                "pytest",
172                "-q",
173                "--junitxml=.speccoder/junit.xml",
174            ],
175            "fast",
176            Some(".speccoder/junit.xml"),
177            &why,
178        ));
179        if found.ruff {
180            out.push(check(
181                "ruff",
182                &["ruff", "check", "."],
183                "fast",
184                None,
185                "ruff is configured",
186            ));
187        }
188        if found.mypy {
189            out.push(check(
190                "mypy",
191                &["mypy", "."],
192                "full",
193                None,
194                "mypy is configured",
195            ));
196        }
197    }
198    if found.go {
199        let name = if out.iter().any(|c| c.name == "test") {
200            "go-test"
201        } else {
202            "test"
203        };
204        out.push(check(
205            name,
206            &["go", "test", "./..."],
207            "fast",
208            None,
209            "go.mod",
210        ));
211        out.push(check(
212            "vet",
213            &["go", "vet", "./..."],
214            "full",
215            None,
216            "go.mod",
217        ));
218    }
219    if out.is_empty() && found.make_test {
220        out.push(check(
221            "test",
222            &["make", "test"],
223            "fast",
224            None,
225            "a Makefile `test` target",
226        ));
227    }
228    out
229}
230
231/// Commands the model (and the spec-derived acceptance tests) need besides
232/// the checks: narrow prefixes per toolchain, written to `[commands] allow`.
233#[must_use]
234pub fn allow(found: &Found) -> Vec<Vec<String>> {
235    let v = |a: &[&str]| a.iter().map(|x| (*x).to_owned()).collect::<Vec<String>>();
236    let mut out = Vec::new();
237    if found.cargo {
238        out.extend([
239            v(&["cargo", "test"]),
240            v(&["cargo", "check"]),
241            v(&["cargo", "build"]),
242        ]);
243    }
244    if let Some(pm) = &found.node_manager {
245        out.extend([v(&[pm, "test"]), v(&[pm, "run", "test"])]);
246    }
247    if found.python {
248        out.push(v(&[
249            found.python_bin.as_deref().unwrap_or("python3"),
250            "-m",
251            "pytest",
252        ]));
253    }
254    if found.go {
255        out.extend([v(&["go", "test"]), v(&["go", "build"]), v(&["go", "vet"])]);
256    }
257    out
258}
259
260/// The formatter speccoder runs after each attempt, when the toolchain has one.
261#[must_use]
262pub fn formatter(found: &Found) -> Option<Vec<String>> {
263    let v = |a: &[&str]| Some(a.iter().map(|x| (*x).to_owned()).collect());
264    if found.cargo {
265        v(&["cargo", "fmt", "--all"])
266    } else if found.go {
267        v(&["gofmt", "-w", "."])
268    } else if found.python && found.ruff {
269        v(&["ruff", "format", "."])
270    } else {
271        None
272    }
273}
274
275/// Looks at `root` (and whether `cargo nextest` runs).
276#[must_use]
277pub fn detect(root: &Path) -> Found {
278    let exists = |p: &str| root.join(p).exists();
279    let read = |p: &str| std::fs::read_to_string(root.join(p)).unwrap_or_default();
280    let cargo = exists("Cargo.toml");
281    let nextest = cargo
282        && std::process::Command::new("cargo")
283            .args(["nextest", "--version"])
284            .stdin(std::process::Stdio::null())
285            .stdout(std::process::Stdio::null())
286            .stderr(std::process::Stdio::null())
287            .status()
288            .is_ok_and(|s| s.success());
289    let (node_manager, npm_scripts) = if exists("package.json") {
290        let manager = if exists("pnpm-lock.yaml") {
291            "pnpm"
292        } else if exists("yarn.lock") {
293            "yarn"
294        } else if exists("bun.lockb") || exists("bun.lock") {
295            "bun"
296        } else {
297            "npm"
298        };
299        let scripts = serde_json::from_str::<serde_json::Value>(&read("package.json"))
300            .ok()
301            .and_then(|v| {
302                v["scripts"]
303                    .as_object()
304                    .map(|o| o.keys().cloned().collect())
305            })
306            .unwrap_or_default();
307        (Some(manager.to_owned()), scripts)
308    } else {
309        (None, Vec::new())
310    };
311    let pyproject = read("pyproject.toml");
312    let makefile = read("Makefile");
313    let python = ["pyproject.toml", "setup.cfg", "setup.py", "pytest.ini"]
314        .iter()
315        .any(|p| exists(p));
316    // A project-local virtualenv: its interpreter, by the path checks run it.
317    let python_bin = [".venv/bin/python", "venv/bin/python"]
318        .iter()
319        .find(|p| python && exists(p))
320        .map(|p| (*p).to_owned());
321    Found {
322        cargo,
323        nextest,
324        npm_scripts,
325        node_manager,
326        python,
327        pytest: python && {
328            let bin = python_bin.as_deref().unwrap_or("python3");
329            std::process::Command::new(if root.join(bin).is_file() {
330                root.join(bin)
331            } else {
332                bin.into()
333            })
334            .args(["-m", "pytest", "--version"])
335            .current_dir(root)
336            .stdin(std::process::Stdio::null())
337            .stdout(std::process::Stdio::null())
338            .stderr(std::process::Stdio::null())
339            .status()
340            .is_ok_and(|s| s.success())
341        },
342        python_bin,
343        ruff: pyproject.contains("[tool.ruff") || exists("ruff.toml") || exists(".ruff.toml"),
344        mypy: pyproject.contains("[tool.mypy") || exists("mypy.ini"),
345        go: exists("go.mod"),
346        make_test: makefile.lines().any(|l| l.starts_with("test:")),
347    }
348}
349
350/// A TOML basic string (JSON's escapes are valid TOML).
351fn quote(s: &str) -> String {
352    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_owned())
353}
354
355/// The provider and model `init` writes: the flag, else the provider whose
356/// key variable is set (presence only — the value is never read).
357fn model_choice(
358    provider: Option<&str>,
359    model: Option<&str>,
360    env: &dyn Fn(&str) -> bool,
361) -> (String, String) {
362    let provider = provider.map_or_else(
363        || {
364            if env("ANTHROPIC_API_KEY") || !env("OPENAI_API_KEY") {
365                "anthropic".to_owned()
366            } else {
367                "openai".to_owned()
368            }
369        },
370        str::to_owned,
371    );
372    let model = model.map_or_else(
373        || match provider.as_str() {
374            "openai" => "gpt-5.4-mini".to_owned(),
375            _ => "claude-sonnet-5-5".to_owned(),
376        },
377        str::to_owned,
378    );
379    (provider, model)
380}
381
382/// The file `init` writes.
383#[must_use]
384pub fn render(
385    checks: &[Proposed],
386    allow: &[Vec<String>],
387    format: Option<&[String]>,
388    provider: &str,
389    model: &str,
390) -> String {
391    let mut out = String::from(
392        "# speccoder configuration — written by `speccoder init`; review it, then commit it\n\
393         # (runs read the committed file in their worktree).\n\n\
394         # Checks: speccoder runs them before and after every attempt and decides the\n\
395         # verdict from them. `fast` runs per task; `full` at phase ends and `verify --tier full`.\n",
396    );
397    if checks.is_empty() {
398        out.push_str(
399            "# No checks were detected. Add at least one, e.g.:\n\
400             # [checks.test]\n# argv = [\"make\", \"test\"]\n",
401        );
402    }
403    for c in checks {
404        out.push_str(&format!("\n# {}\n[checks.{}]\nargv = [", c.why, c.name));
405        out.push_str(
406            &c.argv
407                .iter()
408                .map(|a| quote(a))
409                .collect::<Vec<_>>()
410                .join(", "),
411        );
412        out.push_str("]\n");
413        if c.tier != "fast" {
414            out.push_str(&format!("tier = {}\n", quote(&c.tier)));
415        }
416        if let Some(j) = &c.junit {
417            out.push_str(&format!("junit = {}\n", quote(j)));
418        }
419    }
420    out.push_str(&format!(
421        "\n# The model. The API key comes only from the environment ({}).\n\
422         [model]\nprovider = {}\nmodel = {}\n\
423         # price_input_per_mtok = 0.0       # USD per million tokens, for `speccoder report --cost`\n\
424         # price_output_per_mtok = 0.0\n\
425         # price_cache_read_per_mtok = 0.0\n\
426         \n# Limits per attempt, and attempts per task (a failed verdict is retried once).\n\
427         # [budget]\n# max_turns = 30\n# max_tool_calls = 100\n# max_attempts = 2\n\
428         \n# Test files that existed before an attempt: \"ask\" before any change (default), or\n\
429         # \"additions\" to let new tests be added without asking (changed lines still ask).\n\
430         # [tests]\n# frozen = \"additions\"\n\
431         \n# Commands run in an OS sandbox: writes only to the worktree, credentials unreadable,\n\
432         # no network beyond loopback. Give commands that fetch the network, or switch it off.\n\
433         # [sandbox]\n# network_allow = [[\"cargo\", \"fetch\"]]\n# mode = \"auto\"           # \"required\" refuses to run unsandboxed; \"off\"\n\
434         \n# Commands the model may run besides the checks (argv prefixes); others need approval.\n",
435        if provider == "openai" {
436            "OPENAI_API_KEY"
437        } else {
438            "ANTHROPIC_API_KEY"
439        },
440        quote(provider),
441        quote(model)
442    ));
443    if let Some(argv) = format {
444        out.push_str(&format!(
445            "\n# The formatter speccoder runs after each attempt, before the checks.\n[format]\nargv = [{}]\n\n",
446            argv.iter().map(|a| quote(a)).collect::<Vec<_>>().join(", ")
447        ));
448    }
449    if allow.is_empty() {
450        out.push_str("# [commands]\n# allow = [[\"make\", \"check\"]]\n");
451    } else {
452        let lists: Vec<String> = allow
453            .iter()
454            .map(|a| {
455                format!(
456                    "[{}]",
457                    a.iter().map(|x| quote(x)).collect::<Vec<_>>().join(", ")
458                )
459            })
460            .collect();
461        out.push_str(&format!("[commands]\nallow = [{}]\n", lists.join(", ")));
462    }
463    out
464}
465
466/// The nextest profile speccoder's JUnit report path expects.
467pub const NEXTEST_PROFILE: &str = "\n# Written by `speccoder init`: speccoder reads this JUnit report.\n[profile.speccoder]\nfail-fast = false\n\n[profile.speccoder.junit]\npath = \"junit.xml\"\n";
468
469/// `speccoder init [--provider] [--model] [--force]` with an
470/// explicit view of the environment and of what is installed.
471pub fn init_with(
472    path: &Path,
473    provider: Option<&str>,
474    model: Option<&str>,
475    force: bool,
476    found: Option<Found>,
477    env: &dyn Fn(&str) -> bool,
478) -> CommandResult {
479    let refuse = |status, m: String| CommandResult::new("init", status, &m);
480    // The Spec Kit root when there is one; else the path itself.
481    let root = match crate::inspect(path, None).data() {
482        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
483        _ => path.to_path_buf(),
484    };
485    let file = root.join(FILE);
486    if file.exists() && !force {
487        // It exists: change it, or start it over — never `init` again.
488        return refuse(
489            CommandStatus::ConfigError,
490            format!("{} exists already", file.display()),
491        )
492        .with_code("CONFIG-EXISTS")
493        .with_fix(vec![
494            "speccoder config".to_owned(),
495            "speccoder init --force".to_owned(),
496        ]);
497    }
498    if let Some(p) = provider.filter(|p| !matches!(*p, "anthropic" | "openai")) {
499        return refuse(
500            CommandStatus::UsageError,
501            format!("--provider {p}: anthropic or openai"),
502        );
503    }
504    let found = found.unwrap_or_else(|| detect(&root));
505    let checks = propose(&found);
506    let (provider, model) = model_choice(provider, model, env);
507    let mut text = render(
508        &checks,
509        &allow(&found),
510        formatter(&found).as_deref(),
511        &provider,
512        &model,
513    );
514    // The repository's instructions for agents: AGENTS.md is read by
515    // default; a repository that only has CLAUDE.md gets it named too.
516    let instructions: Vec<String> = ["AGENTS.md", "CLAUDE.md"]
517        .iter()
518        .filter(|n| root.join(n).is_file())
519        .map(|n| (*n).to_owned())
520        .collect();
521    if instructions == ["CLAUDE.md"] {
522        text.push_str(
523            "\n# The repository's instructions for agents, given to every attempt: CLAUDE.md\n# here, AGENTS.md wherever a directory has one.\n[context]\ninstructions = [\"AGENTS.md\", \"CLAUDE.md\"]\n",
524        );
525    }
526    let variable = if provider == "openai" {
527        "OPENAI_API_KEY"
528    } else {
529        "ANTHROPIC_API_KEY"
530    }
531    .to_owned();
532    let key = speccoder_core::InitKey {
533        set: env(&variable),
534        variable,
535    };
536    // Never write what speccoder itself would refuse.
537    if let Err(e) = crate::verify::config::parse(&text)
538        .and_then(|_| crate::substrate::model_config::parse(&text).map(|_| ()))
539        .and_then(|()| crate::substrate::run_config::parse(&text).map(|_| ()))
540    {
541        return refuse(
542            CommandStatus::Error,
543            format!("generated configuration is invalid: {e}"),
544        );
545    }
546    let mut wrote = Vec::new();
547    if let Err(e) = std::fs::write(&file, &text) {
548        return refuse(CommandStatus::Error, format!("{}: {e}", file.display()));
549    }
550    wrote.push(FILE.to_owned());
551    // The nextest profile the `test` check names, unless one is there.
552    if checks.iter().any(|c| c.argv.iter().any(|a| a == "nextest")) {
553        let nextest = root.join(".config/nextest.toml");
554        let existing = std::fs::read_to_string(&nextest).unwrap_or_default();
555        if !existing.contains("[profile.speccoder]") {
556            let _ = std::fs::create_dir_all(root.join(".config"));
557            if std::fs::write(&nextest, format!("{existing}{NEXTEST_PROFILE}")).is_ok() {
558                wrote.push(".config/nextest.toml".to_owned());
559            }
560        }
561    }
562    // What checks write — speccoder's reports under .speccoder/, the
563    // toolchains' build output — is output, not source: kept out of git, or
564    // every run would find it in the worktree and set it aside. Added to
565    // .gitignore only when that file is new or has no other uncommitted
566    // change (so committing it commits nothing else).
567    let mut output: Vec<&str> = Vec::new();
568    if checks
569        .iter()
570        .any(|c| c.argv.iter().any(|a| a.contains(".speccoder/")))
571    {
572        output.push(".speccoder/");
573    }
574    for c in &checks {
575        for dir in crate::worktree::build_output(&c.argv) {
576            if !output.contains(dir) {
577                output.push(dir);
578            }
579        }
580    }
581    let git = Git::at(&root);
582    let missing: Vec<&str> = output
583        .into_iter()
584        .filter(|d| {
585            !git.succeeds(&["check-ignore", "-q", "--no-index", d])
586                .unwrap_or(false)
587        })
588        .collect();
589    if !missing.is_empty() {
590        let ignore = root.join(".gitignore");
591        let existing = std::fs::read_to_string(&ignore).unwrap_or_default();
592        let clean = !ignore.exists()
593            || git
594                .line(&["status", "--porcelain", "--", ".gitignore"])
595                .is_ok_and(|l| l.trim().is_empty());
596        if clean {
597            let sep = if existing.is_empty() || existing.ends_with('\n') {
598                ""
599            } else {
600                "\n"
601            };
602            if std::fs::write(
603                &ignore,
604                format!(
605                    "{existing}{sep}# what checks write (speccoder init)\n{}\n",
606                    missing.join("\n")
607                ),
608            )
609            .is_ok()
610            {
611                wrote.push(".gitignore".to_owned());
612            }
613        }
614    }
615    let message = if checks.is_empty() {
616        format!("wrote {FILE} without checks — add one before `speccoder run`")
617    } else {
618        format!(
619            "wrote {} with {} check(s) and {provider}/{model}; review it, then commit it",
620            speccoder_core::words::listed(&wrote),
621            checks.len()
622        )
623    };
624    CommandResult::new("init", CommandStatus::Ok, &message).with_data(CommandData::Init(Box::new(
625        speccoder_core::InitReport {
626            path: file.display().to_string(),
627            checks: checks
628                .iter()
629                .map(|c| speccoder_core::InitCheck {
630                    name: c.name.clone(),
631                    argv: c.argv.clone(),
632                    tier: c.tier.clone(),
633                    why: c.why.clone(),
634                })
635                .collect(),
636            model: format!("{provider}/{model}"),
637            wrote,
638            ran: Vec::new(),
639            instructions,
640            key,
641            committed: None,
642        },
643    )))
644}
645
646/// Whether a test check found no tests to run — an empty suite on a fresh
647/// scaffold: pytest's exit 5, `go test` with only packages
648/// without test files, vitest, jest or nextest reporting nothing to run.
649/// Neither passing nor failing: *no tests yet*.
650#[must_use]
651pub fn no_tests(argv: &[String], exit: Option<i32>, output: &str) -> bool {
652    let has = |w: &str| argv.iter().any(|a| a == w || a.ends_with(&format!("/{w}")));
653    let lower = output.to_lowercase();
654    if has("pytest") {
655        return exit == Some(5);
656    }
657    if has("go") && argv.iter().any(|a| a == "test") {
658        let said: Vec<&str> = output
659            .lines()
660            .map(str::trim)
661            .filter(|l| !l.is_empty())
662            .collect();
663        return exit == Some(0)
664            && !said.is_empty()
665            && said
666                .iter()
667                .all(|l| l.contains("no test files") || l.contains("matched no packages"));
668    }
669    // Only a run that ended as an empty suite ends: a failure that also
670    // says "no tests" somewhere stays a failure.
671    if has("vitest") || has("jest") || argv.iter().any(|a| a == "test") && has("npm") {
672        return exit == Some(0)
673            && (lower.contains("no test files found") || lower.contains("no tests found"));
674    }
675    if has("nextest") {
676        // nextest's own exit status for "no tests to run".
677        return exit == Some(4) && lower.contains("no tests to run");
678    }
679    false
680}
681
682/// Runs the fast-tier checks of the configuration `init` just wrote on the
683/// untouched code, confined like in a run.
684#[must_use]
685pub fn verify_written(root: &Path) -> Vec<speccoder_core::InitRun> {
686    use speccoder_core::verify::CheckStatus;
687    let Ok((runner, _)) = crate::sandbox::runner_at(root) else {
688        return Vec::new();
689    };
690    let Ok(report) =
691        crate::verify::run_checks(root, Some(crate::verify::config::Tier::Fast), &runner)
692    else {
693        return Vec::new();
694    };
695    report
696        .checks
697        .iter()
698        .map(|c| speccoder_core::InitRun {
699            name: c.name.clone(),
700            passed: matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped),
701            status: if no_tests(
702                &c.argv,
703                c.exit_code,
704                &format!("{}\n{}", c.stdout_tail, c.stderr_tail),
705            ) {
706                "no_tests".to_owned()
707            } else {
708                serde_json::to_value(c.status)
709                    .ok()
710                    .and_then(|v| v.as_str().map(str::to_owned))
711                    .unwrap_or_default()
712            },
713            duration_ms: c.duration_ms,
714            reason: (!matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped))
715                .then(|| {
716                    let said = |t: &str| {
717                        t.lines()
718                            .rev()
719                            .map(str::trim)
720                            .find(|l| !l.is_empty())
721                            .map(str::to_owned)
722                    };
723                    said(&c.stderr_tail)
724                        .or_else(|| said(&c.stdout_tail))
725                        .or_else(|| c.note.clone())
726                        .unwrap_or_else(|| {
727                            format!(
728                                "exit {}",
729                                c.exit_code
730                                    .map_or_else(|| "?".to_owned(), |e| e.to_string())
731                            )
732                        })
733                })
734                .map(|r| r.chars().take(200).collect()),
735        })
736        .collect()
737}
738
739/// [`init_with`] for this machine.
740#[must_use]
741pub fn init(
742    path: &Path,
743    provider: Option<&str>,
744    model: Option<&str>,
745    force: bool,
746) -> CommandResult {
747    init_with(path, provider, model, force, None, &|v| {
748        std::env::var_os(v).is_some_and(|x| !x.is_empty())
749    })
750}
751
752/// `init` written and its checks run on the untouched code: what a person
753/// then decides to commit.
754#[derive(Debug, Clone)]
755pub struct Prepared {
756    /// `init`'s own result.
757    pub written: CommandResult,
758    /// Its report, with the checks' runs.
759    pub report: speccoder_core::InitReport,
760    /// The project root the file was written in.
761    pub root: PathBuf,
762    /// Everything the checks need committed: the file, and the nextest
763    /// profile it names when that is not committed yet.
764    pub to_commit: Vec<String>,
765    /// Checks that fail on the untouched code.
766    pub failed: Vec<String>,
767}
768
769/// The files `init` may commit: nothing else, whatever a caller asks.
770pub const COMMITTABLE: &[&str] = &[
771    FILE,
772    ".config/nextest.toml",
773    ".gitignore",
774    "Cargo.lock",
775    "package-lock.json",
776    "pnpm-lock.yaml",
777    "yarn.lock",
778    "uv.lock",
779    "poetry.lock",
780    "go.sum",
781];
782
783/// Writes the configuration and (unless `no_verify`) runs its checks on the
784/// untouched code. A refusal comes back as `Err`.
785///
786/// # Errors
787///
788/// `init`'s refusal (a file exists, no project, …).
789pub fn prepare(
790    path: &Path,
791    provider: Option<&str>,
792    model: Option<&str>,
793    force: bool,
794    no_verify: bool,
795) -> Result<Prepared, Box<CommandResult>> {
796    let written = init(path, provider, model, force);
797    let Some(CommandData::Init(report)) = written.data() else {
798        return Err(Box::new(written));
799    };
800    let mut report = (**report).clone();
801    let root = PathBuf::from(&report.path)
802        .parent()
803        .map(Path::to_path_buf)
804        .unwrap_or_default();
805    if !no_verify && !report.checks.is_empty() {
806        report.ran = verify_written(&root);
807    }
808    let failed = report
809        .ran
810        .iter()
811        .filter(|r| !r.passed && r.status != "no_tests")
812        .map(|r| r.name.clone())
813        .collect();
814    let mut to_commit: Vec<String> = report.wrote.clone();
815    let profile = ".config/nextest.toml";
816    if report
817        .checks
818        .iter()
819        .any(|c| c.argv.iter().any(|a| a == "nextest"))
820        && root.join(profile).exists()
821        && !to_commit.iter().any(|p| p == profile)
822        && Git::at(&root)
823            .line(&["status", "--porcelain", "--", profile])
824            .is_ok_and(|l| !l.trim().is_empty())
825    {
826        to_commit.push(profile.to_owned());
827    }
828    // A lockfile the checks just wrote, never committed: every run would
829    // find it in the worktree, so it goes with the configuration.
830    for lock in crate::worktree::LOCKFILES {
831        if root.join(lock).exists()
832            && !to_commit.iter().any(|p| p == lock)
833            && Git::at(&root)
834                .line(&["status", "--porcelain", "--", lock])
835                .is_ok_and(|l| l.starts_with("??"))
836        {
837            to_commit.push((*lock).to_owned());
838        }
839    }
840    Ok(Prepared {
841        written,
842        report,
843        root,
844        to_commit,
845        failed,
846    })
847}
848
849/// Commits exactly `paths` — each one of [`COMMITTABLE`] — as
850/// `speccoder: config`; the short id.
851///
852/// # Errors
853///
854/// A path `init` does not commit, or git's complaint.
855pub fn commit_written(root: &Path, paths: &[String]) -> Result<String, String> {
856    commit_written_as(root, paths, "speccoder: config")
857}
858
859/// [`commit_written`] with its own subject.
860///
861/// # Errors
862///
863/// A path `init` does not commit, or git's complaint.
864pub fn commit_written_as(root: &Path, paths: &[String], subject: &str) -> Result<String, String> {
865    if paths.is_empty() {
866        return Err("nothing to commit".to_owned());
867    }
868    if let Some(p) = paths.iter().find(|p| !COMMITTABLE.contains(&p.as_str())) {
869        return Err(format!("`{p}` is not a file init commits"));
870    }
871    let g = Git::at(root);
872    let names: Vec<&str> = paths.iter().map(String::as_str).collect();
873    let mut add = vec!["add", "--"];
874    add.extend(&names);
875    let mut commit = vec!["commit", "-q", "-m", subject, "--"];
876    commit.extend(&names);
877    g.run(&add).map_err(|e| e.0)?;
878    // Nothing differs from the last commit: say that, not an empty failure.
879    let mut unchanged = vec!["diff", "--cached", "--quiet", "--"];
880    unchanged.extend(&names);
881    if g.succeeds(&unchanged).unwrap_or(false) {
882        return Err(format!(
883            "nothing to commit: {} is committed as it is",
884            speccoder_core::words::listed(&names)
885        ));
886    }
887    g.run(&commit)
888        .and_then(|_| g.line(&["rev-parse", "--short", "HEAD"]))
889        .map_err(|e| {
890            if e.0.trim().is_empty() {
891                "git commit failed without saying why".to_owned()
892            } else {
893                e.0
894            }
895        })
896}
897
898/// The result of an `init` the person has decided about: the message, the
899/// report, and what comes next.
900#[must_use]
901pub fn finish(p: Prepared) -> CommandResult {
902    let Prepared {
903        written,
904        report,
905        to_commit,
906        failed,
907        ..
908    } = p;
909    let mut message = match &report.committed {
910        Some(id) => written.message().replace(
911            "; review it, then commit it",
912            &format!(" and committed it ({id})"),
913        ),
914        None => written.message().to_owned(),
915    };
916    if !failed.is_empty() {
917        message = format!(
918            "{message}; the check(s) {} fail on the untouched code: every task would be judged against a red baseline",
919            failed.join(", ")
920        );
921    }
922    let step = |command: String, why: &str| speccoder_core::next::NextStep {
923        command,
924        why: why.to_owned(),
925    };
926    let mut next = Vec::new();
927    // No spec project yet: the format's tool makes one — first, before the
928    // commit and the key matter.
929    let root = PathBuf::from(&report.path)
930        .parent()
931        .map(Path::to_path_buf)
932        .unwrap_or_default();
933    let format = crate::format::at(&root);
934    if format.detect(&root).is_none() {
935        let (command, why) = format.messages().make_project;
936        message = format!("{message}; there is no spec project here yet");
937        next.push(step(command.to_owned(), why));
938    }
939    if report.committed.is_none() {
940        let listed = to_commit.join(" ");
941        next.push(step(
942            format!("git add {listed} && git commit -m \"speccoder: config\" -- {listed}"),
943            "speccoder reads committed files only",
944        ));
945    }
946    if !report.key.set {
947        next.push(step(
948            format!("export {}=…", report.key.variable),
949            "the model key, from the environment only",
950        ));
951    }
952    // Something to run, or something to write first.
953    let features = match crate::current::list(&root, None).data() {
954        Some(CommandData::Features(f)) => f.features.len(),
955        _ => 0,
956    };
957    if features == 0 {
958        next.push(step(
959            "speccoder spec specify \"…\"".to_owned(),
960            "describe the first feature; its spec, plan and tasks follow",
961        ));
962    } else {
963        next.push(step(
964            "speccoder run".to_owned(),
965            "work through the ready tasks",
966        ));
967    }
968    CommandResult::new("init", CommandStatus::Ok, &message)
969        .with_data(CommandData::Init(Box::new(report)))
970        .with_next(next)
971}
972
973/// What `init` would write here, without writing: for the setup assistant.
974#[derive(Debug, Clone, Serialize)]
975pub struct Preview {
976    /// Whether the project already has a `speccoder.toml`.
977    pub configured: bool,
978    /// The checks it would declare.
979    pub checks: Vec<Proposed>,
980    /// Commands the model could run without asking.
981    pub allow: Vec<Vec<String>>,
982    /// The formatter it would configure.
983    pub formatter: Option<Vec<String>>,
984    /// The model key variables, and whether each is set.
985    pub keys: Vec<(String, bool)>,
986}
987
988/// Where `init` writes for `path`: the spec project's root, else `path`.
989#[must_use]
990pub fn root_of(path: &Path) -> PathBuf {
991    match crate::inspect(path, None).data() {
992        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
993        _ => path.to_path_buf(),
994    }
995}
996
997/// [`Preview`] of `path`.
998#[must_use]
999pub fn preview(path: &Path) -> Preview {
1000    let root = root_of(path);
1001    let found = detect(&root);
1002    let set = |v: &str| std::env::var_os(v).is_some_and(|x| !x.is_empty());
1003    Preview {
1004        configured: root.join(FILE).is_file(),
1005        checks: propose(&found),
1006        allow: allow(&found),
1007        formatter: formatter(&found),
1008        keys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
1009            .iter()
1010            .map(|v| ((*v).to_owned(), set(v)))
1011            .collect(),
1012    }
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018
1019    #[test]
1020    fn proposals_follow_what_was_found() {
1021        let rust = propose(&Found {
1022            cargo: true,
1023            nextest: true,
1024            ..Found::default()
1025        });
1026        let names: Vec<&str> = rust.iter().map(|c| c.name.as_str()).collect();
1027        assert_eq!(names, ["test", "fmt", "clippy"]);
1028        assert_eq!(
1029            rust[0].junit.as_deref(),
1030            Some("target/nextest/speccoder/junit.xml")
1031        );
1032        assert_eq!(rust[2].tier, "full");
1033        let plain = propose(&Found {
1034            cargo: true,
1035            ..Found::default()
1036        });
1037        assert_eq!(plain[0].argv, ["cargo", "test", "--workspace"]);
1038
1039        let node = propose(&Found {
1040            node_manager: Some("pnpm".to_owned()),
1041            npm_scripts: vec!["test".into(), "lint".into(), "build".into()],
1042            ..Found::default()
1043        });
1044        let shown: Vec<(String, String)> = node
1045            .iter()
1046            .map(|c| (c.name.clone(), c.argv.join(" ")))
1047            .collect();
1048        assert_eq!(
1049            shown,
1050            [
1051                ("test".into(), "pnpm run test".into()),
1052                ("lint".into(), "pnpm run lint".into())
1053            ]
1054        );
1055        let npm = propose(&Found {
1056            node_manager: Some("npm".to_owned()),
1057            npm_scripts: vec!["test".into()],
1058            ..Found::default()
1059        });
1060        assert_eq!(npm[0].argv, ["npm", "run", "test"]);
1061
1062        let py = propose(&Found {
1063            python: true,
1064            ruff: true,
1065            ..Found::default()
1066        });
1067        assert_eq!(
1068            py.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
1069            ["test", "ruff"]
1070        );
1071        let go = propose(&Found {
1072            go: true,
1073            ..Found::default()
1074        });
1075        assert_eq!(go[0].argv, ["go", "test", "./..."]);
1076        let make = propose(&Found {
1077            make_test: true,
1078            ..Found::default()
1079        });
1080        assert_eq!(make[0].argv, ["make", "test"]);
1081        assert!(propose(&Found::default()).is_empty());
1082    }
1083
1084    #[test]
1085    fn the_model_follows_the_key_that_is_set() {
1086        let none = |_: &str| false;
1087        let openai = |v: &str| v == "OPENAI_API_KEY";
1088        assert_eq!(model_choice(None, None, &none).0, "anthropic");
1089        assert_eq!(
1090            model_choice(None, None, &openai),
1091            ("openai".to_owned(), "gpt-5.4-mini".to_owned())
1092        );
1093        assert_eq!(
1094            model_choice(Some("anthropic"), Some("m"), &openai),
1095            ("anthropic".to_owned(), "m".to_owned())
1096        );
1097    }
1098
1099    #[test]
1100    fn every_rendered_file_parses() {
1101        for found in [
1102            Found {
1103                cargo: true,
1104                nextest: true,
1105                ..Found::default()
1106            },
1107            Found {
1108                python: true,
1109                ruff: true,
1110                mypy: true,
1111                go: true,
1112                ..Found::default()
1113            },
1114            Found::default(),
1115        ] {
1116            let text = render(
1117                &propose(&found),
1118                &allow(&found),
1119                formatter(&found).as_deref(),
1120                "openai",
1121                "gpt-5.4-mini",
1122            );
1123            if found.cargo {
1124                assert!(
1125                    text.contains("[format]\nargv = [\"cargo\", \"fmt\", \"--all\"]"),
1126                    "{text}"
1127                );
1128                assert_eq!(
1129                    crate::substrate::run_config::parse(&text)
1130                        .ok()
1131                        .and_then(|c| c.format),
1132                    Some(vec![
1133                        "cargo".to_owned(),
1134                        "fmt".to_owned(),
1135                        "--all".to_owned()
1136                    ])
1137                );
1138            }
1139            let checks =
1140                crate::verify::config::parse(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
1141            assert_eq!(checks.len(), propose(&found).len());
1142            assert!(crate::substrate::model_config::parse(&text).is_ok_and(|m| m.is_some()));
1143            assert!(crate::substrate::run_config::parse(&text).is_ok());
1144        }
1145    }
1146
1147    #[test]
1148    fn an_empty_suite_on_a_fresh_scaffold_is_no_tests_yet() {
1149        let argv = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
1150        assert!(no_tests(
1151            &argv(&["uv", "run", "pytest"]),
1152            Some(5),
1153            "no tests ran"
1154        ));
1155        assert!(!no_tests(
1156            &argv(&["uv", "run", "pytest"]),
1157            Some(1),
1158            "1 failed"
1159        ));
1160        assert!(no_tests(
1161            &argv(&["go", "test", "./..."]),
1162            Some(0),
1163            "go: warning: \"./...\" matched no packages\n"
1164        ));
1165        assert!(no_tests(
1166            &argv(&["go", "test", "./..."]),
1167            Some(0),
1168            "?   \tx\t[no test files]\n"
1169        ));
1170        assert!(!no_tests(
1171            &argv(&["go", "test", "./..."]),
1172            Some(0),
1173            "ok  \tx\t0.1s\n"
1174        ));
1175        assert!(no_tests(
1176            &argv(&["npm", "test"]),
1177            Some(0),
1178            "No test files found, exiting with code 0"
1179        ));
1180        assert!(no_tests(
1181            &argv(&["npx", "vitest", "run"]),
1182            Some(0),
1183            "No test files found"
1184        ));
1185        assert!(no_tests(
1186            &argv(&["cargo", "nextest", "run"]),
1187            Some(4),
1188            "error: no tests to run"
1189        ));
1190        assert!(!no_tests(
1191            &argv(&["cargo", "test"]),
1192            Some(0),
1193            "test result: ok. 1 passed"
1194        ));
1195    }
1196}