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