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