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    // Reports a check writes under .speccoder/ are output, not source: kept
563    // out of git — added to .gitignore only when that file is new or has no
564    // other uncommitted change (so committing it commits nothing else).
565    if checks
566        .iter()
567        .any(|c| c.argv.iter().any(|a| a.contains(".speccoder/")))
568    {
569        let ignore = root.join(".gitignore");
570        let existing = std::fs::read_to_string(&ignore).unwrap_or_default();
571        let listed = existing.lines().any(|l| {
572            matches!(
573                l.trim(),
574                ".speccoder" | ".speccoder/" | "/.speccoder/" | "/.speccoder"
575            )
576        });
577        let clean = !ignore.exists()
578            || Git::at(&root)
579                .line(&["status", "--porcelain", "--", ".gitignore"])
580                .is_ok_and(|l| l.trim().is_empty());
581        if !listed && clean {
582            let sep = if existing.is_empty() || existing.ends_with('\n') {
583                ""
584            } else {
585                "\n"
586            };
587            if std::fs::write(
588                &ignore,
589                format!("{existing}{sep}# speccoder's check reports\n.speccoder/\n"),
590            )
591            .is_ok()
592            {
593                wrote.push(".gitignore".to_owned());
594            }
595        }
596    }
597    let message = if checks.is_empty() {
598        format!("wrote {FILE} without checks — add one before `speccoder run`")
599    } else {
600        format!(
601            "wrote {} with {} check(s) and {provider}/{model}; review it, then commit it",
602            wrote.join(" and "),
603            checks.len()
604        )
605    };
606    CommandResult::new("init", CommandStatus::Ok, &message).with_data(CommandData::Init(Box::new(
607        speccoder_core::InitReport {
608            path: file.display().to_string(),
609            checks: checks
610                .iter()
611                .map(|c| speccoder_core::InitCheck {
612                    name: c.name.clone(),
613                    argv: c.argv.clone(),
614                    tier: c.tier.clone(),
615                    why: c.why.clone(),
616                })
617                .collect(),
618            model: format!("{provider}/{model}"),
619            wrote,
620            ran: Vec::new(),
621            instructions,
622            key,
623            committed: None,
624        },
625    )))
626}
627
628/// Runs the fast-tier checks of the configuration `init` just wrote on the
629/// untouched code, confined like in a run.
630#[must_use]
631pub fn verify_written(root: &Path) -> Vec<speccoder_core::InitRun> {
632    use speccoder_core::verify::CheckStatus;
633    let Ok((runner, _)) = crate::sandbox::runner_at(root) else {
634        return Vec::new();
635    };
636    let Ok(report) =
637        crate::verify::run_checks(root, Some(crate::verify::config::Tier::Fast), &runner)
638    else {
639        return Vec::new();
640    };
641    report
642        .checks
643        .iter()
644        .map(|c| speccoder_core::InitRun {
645            name: c.name.clone(),
646            passed: matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped),
647            status: serde_json::to_value(c.status)
648                .ok()
649                .and_then(|v| v.as_str().map(str::to_owned))
650                .unwrap_or_default(),
651            duration_ms: c.duration_ms,
652            reason: (!matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped))
653                .then(|| {
654                    let said = |t: &str| {
655                        t.lines()
656                            .rev()
657                            .map(str::trim)
658                            .find(|l| !l.is_empty())
659                            .map(str::to_owned)
660                    };
661                    said(&c.stderr_tail)
662                        .or_else(|| said(&c.stdout_tail))
663                        .or_else(|| c.note.clone())
664                        .unwrap_or_else(|| {
665                            format!(
666                                "exit {}",
667                                c.exit_code
668                                    .map_or_else(|| "?".to_owned(), |e| e.to_string())
669                            )
670                        })
671                })
672                .map(|r| r.chars().take(200).collect()),
673        })
674        .collect()
675}
676
677/// [`init_with`] for this machine.
678#[must_use]
679pub fn init(
680    path: &Path,
681    provider: Option<&str>,
682    model: Option<&str>,
683    force: bool,
684) -> CommandResult {
685    init_with(path, provider, model, force, None, &|v| {
686        std::env::var_os(v).is_some_and(|x| !x.is_empty())
687    })
688}
689
690/// `init` written and its checks run on the untouched code: what a person
691/// then decides to commit.
692#[derive(Debug, Clone)]
693pub struct Prepared {
694    /// `init`'s own result.
695    pub written: CommandResult,
696    /// Its report, with the checks' runs.
697    pub report: speccoder_core::InitReport,
698    /// The project root the file was written in.
699    pub root: PathBuf,
700    /// Everything the checks need committed: the file, and the nextest
701    /// profile it names when that is not committed yet.
702    pub to_commit: Vec<String>,
703    /// Checks that fail on the untouched code.
704    pub failed: Vec<String>,
705}
706
707/// The files `init` may commit: nothing else, whatever a caller asks.
708pub const COMMITTABLE: &[&str] = &[FILE, ".config/nextest.toml", ".gitignore"];
709
710/// Writes the configuration and (unless `no_verify`) runs its checks on the
711/// untouched code. A refusal comes back as `Err`.
712///
713/// # Errors
714///
715/// `init`'s refusal (a file exists, no project, …).
716pub fn prepare(
717    path: &Path,
718    provider: Option<&str>,
719    model: Option<&str>,
720    force: bool,
721    no_verify: bool,
722) -> Result<Prepared, Box<CommandResult>> {
723    let written = init(path, provider, model, force);
724    let Some(CommandData::Init(report)) = written.data() else {
725        return Err(Box::new(written));
726    };
727    let mut report = (**report).clone();
728    let root = PathBuf::from(&report.path)
729        .parent()
730        .map(Path::to_path_buf)
731        .unwrap_or_default();
732    if !no_verify && !report.checks.is_empty() {
733        report.ran = verify_written(&root);
734    }
735    let failed = report
736        .ran
737        .iter()
738        .filter(|r| !r.passed)
739        .map(|r| r.name.clone())
740        .collect();
741    let mut to_commit: Vec<String> = report.wrote.clone();
742    let profile = ".config/nextest.toml";
743    if report
744        .checks
745        .iter()
746        .any(|c| c.argv.iter().any(|a| a == "nextest"))
747        && root.join(profile).exists()
748        && !to_commit.iter().any(|p| p == profile)
749        && Git::at(&root)
750            .line(&["status", "--porcelain", "--", profile])
751            .is_ok_and(|l| !l.trim().is_empty())
752    {
753        to_commit.push(profile.to_owned());
754    }
755    Ok(Prepared {
756        written,
757        report,
758        root,
759        to_commit,
760        failed,
761    })
762}
763
764/// Commits exactly `paths` — each one of [`COMMITTABLE`] — as
765/// `speccoder: config`; the short id.
766///
767/// # Errors
768///
769/// A path `init` does not commit, or git's complaint.
770pub fn commit_written(root: &Path, paths: &[String]) -> Result<String, String> {
771    if paths.is_empty() {
772        return Err("nothing to commit".to_owned());
773    }
774    if let Some(p) = paths.iter().find(|p| !COMMITTABLE.contains(&p.as_str())) {
775        return Err(format!("`{p}` is not a file init commits"));
776    }
777    let g = Git::at(root);
778    let names: Vec<&str> = paths.iter().map(String::as_str).collect();
779    let mut add = vec!["add", "--"];
780    add.extend(&names);
781    let mut commit = vec!["commit", "-q", "-m", "speccoder: config", "--"];
782    commit.extend(&names);
783    g.run(&add).map_err(|e| e.0)?;
784    // Nothing differs from the last commit: say that, not an empty failure.
785    let mut unchanged = vec!["diff", "--cached", "--quiet", "--"];
786    unchanged.extend(&names);
787    if g.succeeds(&unchanged).unwrap_or(false) {
788        return Err(format!(
789            "nothing to commit: {} is committed as it is",
790            names.join(" and ")
791        ));
792    }
793    g.run(&commit)
794        .and_then(|_| g.line(&["rev-parse", "--short", "HEAD"]))
795        .map_err(|e| {
796            if e.0.trim().is_empty() {
797                "git commit failed without saying why".to_owned()
798            } else {
799                e.0
800            }
801        })
802}
803
804/// The result of an `init` the person has decided about: the message, the
805/// report, and what comes next.
806#[must_use]
807pub fn finish(p: Prepared) -> CommandResult {
808    let Prepared {
809        written,
810        report,
811        to_commit,
812        failed,
813        ..
814    } = p;
815    let mut message = match &report.committed {
816        Some(id) => written.message().replace(
817            "; review it, then commit it",
818            &format!(" and committed it ({id})"),
819        ),
820        None => written.message().to_owned(),
821    };
822    if !failed.is_empty() {
823        message = format!(
824            "{message}; the check(s) {} fail on the untouched code: every task would be judged against a red baseline",
825            failed.join(", ")
826        );
827    }
828    let step = |command: String, why: &str| speccoder_core::next::NextStep {
829        command,
830        why: why.to_owned(),
831    };
832    let mut next = Vec::new();
833    // No spec project yet: the format's tool makes one — first, before the
834    // commit and the key matter.
835    let root = PathBuf::from(&report.path)
836        .parent()
837        .map(Path::to_path_buf)
838        .unwrap_or_default();
839    let format = crate::format::at(&root);
840    if format.detect(&root).is_none() {
841        let (command, why) = format.messages().make_project;
842        message = format!("{message}; there is no spec project here yet");
843        next.push(step(command.to_owned(), why));
844    }
845    if report.committed.is_none() {
846        let listed = to_commit.join(" ");
847        next.push(step(
848            format!("git add {listed} && git commit -m \"speccoder: config\" -- {listed}"),
849            "speccoder reads committed files only",
850        ));
851    }
852    if !report.key.set {
853        next.push(step(
854            format!("export {}=…", report.key.variable),
855            "the model key, from the environment only",
856        ));
857    }
858    // Something to run, or something to write first.
859    let features = match crate::current::list(&root, None).data() {
860        Some(CommandData::Features(f)) => f.features.len(),
861        _ => 0,
862    };
863    if features == 0 {
864        next.push(step(
865            "speccoder spec specify \"…\"".to_owned(),
866            "describe the first feature; its spec, plan and tasks follow",
867        ));
868    } else {
869        next.push(step(
870            "speccoder run".to_owned(),
871            "work through the ready tasks",
872        ));
873    }
874    CommandResult::new("init", CommandStatus::Ok, &message)
875        .with_data(CommandData::Init(Box::new(report)))
876        .with_next(next)
877}
878
879/// What `init` would write here, without writing: for the setup assistant.
880#[derive(Debug, Clone, Serialize)]
881pub struct Preview {
882    /// Whether the project already has a `speccoder.toml`.
883    pub configured: bool,
884    /// The checks it would declare.
885    pub checks: Vec<Proposed>,
886    /// Commands the model could run without asking.
887    pub allow: Vec<Vec<String>>,
888    /// The formatter it would configure.
889    pub formatter: Option<Vec<String>>,
890    /// The model key variables, and whether each is set.
891    pub keys: Vec<(String, bool)>,
892}
893
894/// Where `init` writes for `path`: the spec project's root, else `path`.
895#[must_use]
896pub fn root_of(path: &Path) -> PathBuf {
897    match crate::inspect(path, None).data() {
898        Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
899        _ => path.to_path_buf(),
900    }
901}
902
903/// [`Preview`] of `path`.
904#[must_use]
905pub fn preview(path: &Path) -> Preview {
906    let root = root_of(path);
907    let found = detect(&root);
908    let set = |v: &str| std::env::var_os(v).is_some_and(|x| !x.is_empty());
909    Preview {
910        configured: root.join(FILE).is_file(),
911        checks: propose(&found),
912        allow: allow(&found),
913        formatter: formatter(&found),
914        keys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
915            .iter()
916            .map(|v| ((*v).to_owned(), set(v)))
917            .collect(),
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    #[test]
926    fn proposals_follow_what_was_found() {
927        let rust = propose(&Found {
928            cargo: true,
929            nextest: true,
930            ..Found::default()
931        });
932        let names: Vec<&str> = rust.iter().map(|c| c.name.as_str()).collect();
933        assert_eq!(names, ["test", "fmt", "clippy"]);
934        assert_eq!(
935            rust[0].junit.as_deref(),
936            Some("target/nextest/speccoder/junit.xml")
937        );
938        assert_eq!(rust[2].tier, "full");
939        let plain = propose(&Found {
940            cargo: true,
941            ..Found::default()
942        });
943        assert_eq!(plain[0].argv, ["cargo", "test", "--workspace"]);
944
945        let node = propose(&Found {
946            node_manager: Some("pnpm".to_owned()),
947            npm_scripts: vec!["test".into(), "lint".into(), "build".into()],
948            ..Found::default()
949        });
950        let shown: Vec<(String, String)> = node
951            .iter()
952            .map(|c| (c.name.clone(), c.argv.join(" ")))
953            .collect();
954        assert_eq!(
955            shown,
956            [
957                ("test".into(), "pnpm run test".into()),
958                ("lint".into(), "pnpm run lint".into())
959            ]
960        );
961        let npm = propose(&Found {
962            node_manager: Some("npm".to_owned()),
963            npm_scripts: vec!["test".into()],
964            ..Found::default()
965        });
966        assert_eq!(npm[0].argv, ["npm", "run", "test"]);
967
968        let py = propose(&Found {
969            python: true,
970            ruff: true,
971            ..Found::default()
972        });
973        assert_eq!(
974            py.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
975            ["test", "ruff"]
976        );
977        let go = propose(&Found {
978            go: true,
979            ..Found::default()
980        });
981        assert_eq!(go[0].argv, ["go", "test", "./..."]);
982        let make = propose(&Found {
983            make_test: true,
984            ..Found::default()
985        });
986        assert_eq!(make[0].argv, ["make", "test"]);
987        assert!(propose(&Found::default()).is_empty());
988    }
989
990    #[test]
991    fn the_model_follows_the_key_that_is_set() {
992        let none = |_: &str| false;
993        let openai = |v: &str| v == "OPENAI_API_KEY";
994        assert_eq!(model_choice(None, None, &none).0, "anthropic");
995        assert_eq!(
996            model_choice(None, None, &openai),
997            ("openai".to_owned(), "gpt-5.4-mini".to_owned())
998        );
999        assert_eq!(
1000            model_choice(Some("anthropic"), Some("m"), &openai),
1001            ("anthropic".to_owned(), "m".to_owned())
1002        );
1003    }
1004
1005    #[test]
1006    fn every_rendered_file_parses() {
1007        for found in [
1008            Found {
1009                cargo: true,
1010                nextest: true,
1011                ..Found::default()
1012            },
1013            Found {
1014                python: true,
1015                ruff: true,
1016                mypy: true,
1017                go: true,
1018                ..Found::default()
1019            },
1020            Found::default(),
1021        ] {
1022            let text = render(
1023                &propose(&found),
1024                &allow(&found),
1025                formatter(&found).as_deref(),
1026                "openai",
1027                "gpt-5.4-mini",
1028            );
1029            if found.cargo {
1030                assert!(
1031                    text.contains("[format]\nargv = [\"cargo\", \"fmt\", \"--all\"]"),
1032                    "{text}"
1033                );
1034                assert_eq!(
1035                    crate::substrate::run_config::parse(&text)
1036                        .ok()
1037                        .and_then(|c| c.format),
1038                    Some(vec![
1039                        "cargo".to_owned(),
1040                        "fmt".to_owned(),
1041                        "--all".to_owned()
1042                    ])
1043                );
1044            }
1045            let checks =
1046                crate::verify::config::parse(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
1047            assert_eq!(checks.len(), propose(&found).len());
1048            assert!(crate::substrate::model_config::parse(&text).is_ok_and(|m| m.is_some()));
1049            assert!(crate::substrate::run_config::parse(&text).is_ok());
1050        }
1051    }
1052}