use std::path::{Path, PathBuf};
use serde::Serialize;
use speccoder_core::{CommandData, CommandResult, CommandStatus};
use crate::git::Git;
use crate::verify::config::FILE;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Found {
pub cargo: bool,
pub nextest: bool,
pub npm_scripts: Vec<String>,
pub node_manager: Option<String>,
pub python: bool,
pub python_run: Vec<String>,
pub pytest: bool,
pub ruff: bool,
pub mypy: bool,
pub go: bool,
pub make_test: bool,
pub include: Vec<String>,
pub worktreeinclude: bool,
}
impl Found {
#[must_use]
pub fn python_command(&self) -> Vec<String> {
if self.python_run.is_empty() {
vec!["python3".to_owned()]
} else {
self.python_run.clone()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Proposed {
pub name: String,
pub argv: Vec<String>,
pub tier: String,
pub junit: Option<String>,
pub why: String,
}
fn check(name: &str, argv: &[&str], tier: &str, junit: Option<&str>, why: &str) -> Proposed {
Proposed {
name: name.to_owned(),
argv: argv.iter().map(|a| (*a).to_owned()).collect(),
tier: tier.to_owned(),
junit: junit.map(str::to_owned),
why: why.to_owned(),
}
}
#[must_use]
pub fn propose(found: &Found) -> Vec<Proposed> {
let mut out = Vec::new();
if found.cargo {
out.push(if found.nextest {
check(
"test",
&[
"cargo",
"nextest",
"run",
"--workspace",
"--profile",
"speccoder",
"--no-tests=warn",
],
"fast",
Some("target/nextest/speccoder/junit.xml"),
"Cargo.toml; cargo-nextest is installed (its JUnit report names every test)",
)
} else {
check(
"test",
&["cargo", "test", "--workspace"],
"fast",
None,
"Cargo.toml (install cargo-nextest for per-test results)",
)
});
out.push(check(
"fmt",
&["cargo", "fmt", "--all", "--check"],
"fast",
None,
"Cargo.toml",
));
out.push(check(
"clippy",
&[
"cargo",
"clippy",
"--workspace",
"--all-targets",
"--",
"-D",
"warnings",
],
"full",
None,
"Cargo.toml",
));
}
if let Some(pm) = &found.node_manager {
let has = |s: &str| found.npm_scripts.iter().any(|x| x == s);
let run = |script: &str| -> Vec<String> { vec![pm.clone(), "run".into(), script.into()] };
let mut add = |name: &str, script: &str, tier: &str| {
out.push(Proposed {
name: name.to_owned(),
argv: run(script),
tier: tier.to_owned(),
junit: None,
why: format!("package.json script `{script}` ({pm})"),
});
};
if has("test") {
add(
if found.cargo { "node-test" } else { "test" },
"test",
"fast",
);
}
if has("typecheck") {
add("typecheck", "typecheck", "fast");
}
if has("lint") {
add("lint", "lint", "full");
}
}
if found.python {
let name = if found.cargo || found.node_manager.is_some() {
"pytest"
} else {
"test"
};
let run = found.python_command();
let python = run.join(" ");
let uv = run.first().is_some_and(|p| p == "uv");
let why = if found.pytest {
format!("a Python project (pytest, {python})")
} else if uv {
"a Python project — pytest is not a dependency: `uv add --dev pytest`".to_owned()
} else {
format!(
"a Python project — pytest is not installed for {python}: `{python} -m pip install pytest`"
)
};
let argv: Vec<&str> = run
.iter()
.map(String::as_str)
.chain(["-m", "pytest", "-q", "--junitxml=.speccoder/junit.xml"])
.collect();
out.push(check(
name,
&argv,
"fast",
Some(".speccoder/junit.xml"),
&why,
));
if found.ruff {
out.push(check(
"ruff",
&["ruff", "check", "."],
"fast",
None,
"ruff is configured",
));
}
if found.mypy {
out.push(check(
"mypy",
&["mypy", "."],
"full",
None,
"mypy is configured",
));
}
}
if found.go {
let name = if out.iter().any(|c| c.name == "test") {
"go-test"
} else {
"test"
};
out.push(check(
name,
&["go", "test", "./..."],
"fast",
None,
"go.mod",
));
out.push(check(
"vet",
&["go", "vet", "./..."],
"full",
None,
"go.mod",
));
}
if out.is_empty() && found.make_test {
out.push(check(
"test",
&["make", "test"],
"fast",
None,
"a Makefile `test` target",
));
}
out
}
#[must_use]
pub fn allow(found: &Found) -> Vec<Vec<String>> {
let v = |a: &[&str]| a.iter().map(|x| (*x).to_owned()).collect::<Vec<String>>();
let mut out = Vec::new();
if found.cargo {
out.extend([
v(&["cargo", "test"]),
v(&["cargo", "check"]),
v(&["cargo", "build"]),
]);
}
if let Some(pm) = &found.node_manager {
out.extend([v(&[pm, "test"]), v(&[pm, "run", "test"])]);
}
if found.python {
let mut pytest = found.python_command();
pytest.extend(["-m".to_owned(), "pytest".to_owned()]);
out.push(pytest);
}
if found.go {
out.extend([v(&["go", "test"]), v(&["go", "build"]), v(&["go", "vet"])]);
}
out
}
#[must_use]
pub fn formatter(found: &Found) -> Option<Vec<String>> {
let v = |a: &[&str]| Some(a.iter().map(|x| (*x).to_owned()).collect());
if found.cargo {
v(&["cargo", "fmt", "--all"])
} else if found.go {
v(&["gofmt", "-w", "."])
} else if found.python && found.ruff {
v(&["ruff", "format", "."])
} else {
None
}
}
#[must_use]
pub fn detect(root: &Path) -> Found {
let exists = |p: &str| root.join(p).exists();
let read = |p: &str| std::fs::read_to_string(root.join(p)).unwrap_or_default();
let cargo = exists("Cargo.toml");
let nextest = cargo
&& std::process::Command::new("cargo")
.args(["nextest", "--version"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success());
let (node_manager, npm_scripts) = if exists("package.json") {
let manager = if exists("pnpm-lock.yaml") {
"pnpm"
} else if exists("yarn.lock") {
"yarn"
} else if exists("bun.lockb") || exists("bun.lock") {
"bun"
} else {
"npm"
};
let scripts = serde_json::from_str::<serde_json::Value>(&read("package.json"))
.ok()
.and_then(|v| {
v["scripts"]
.as_object()
.map(|o| o.keys().cloned().collect())
})
.unwrap_or_default();
(Some(manager.to_owned()), scripts)
} else {
(None, Vec::new())
};
let pyproject = read("pyproject.toml");
let makefile = read("Makefile");
let python = ["pyproject.toml", "setup.cfg", "setup.py", "pytest.ini"]
.iter()
.any(|p| exists(p));
let words = |w: &[&str]| w.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
let python_run = if python && exists("uv.lock") {
words(&["uv", "run", "--locked", "--offline", "python"])
} else {
[".venv/bin/python", "venv/bin/python"]
.iter()
.find(|p| python && exists(p))
.map_or_else(|| words(&["python3"]), |p| words(&[p]))
};
Found {
cargo,
nextest,
npm_scripts,
node_manager,
python,
pytest: python && {
let bin = python_run[0].as_str();
std::process::Command::new(if root.join(bin).is_file() {
root.join(bin)
} else {
bin.into()
})
.args(&python_run[1..])
.args(["-m", "pytest", "--version"])
.current_dir(root)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
},
python_run,
ruff: pyproject.contains("[tool.ruff") || exists("ruff.toml") || exists(".ruff.toml"),
mypy: pyproject.contains("[tool.mypy") || exists("mypy.ini"),
go: exists("go.mod"),
make_test: makefile.lines().any(|l| l.starts_with("test:")),
include: env_files(root),
worktreeinclude: root.join(crate::include::FILE).is_file(),
}
}
#[must_use]
pub fn env_files(root: &Path) -> Vec<String> {
crate::include::matched(root, &[".env".to_owned(), ".env.*".to_owned()])
}
const INCLUDE_PROPOSED: &str = "# the git-ignored .env files here; to copy them, uncomment:";
fn worktree_section(include: &[String], worktreeinclude: bool) -> String {
if worktreeinclude {
return "\n# A run's worktree gets the git-ignored files .worktreeinclude names copied from this\n\
# checkout (never committed, secret-named values masked); `include` adds patterns.\n\
# [worktree]\n# prepare = \"auto\" # \"off\": never install Node or uv dependencies\n\
# include = [\".env\"]\n"
.to_owned();
}
if include.is_empty() {
return "\n# A run's worktree gets Node and uv dependencies by their locked, offline install\n\
# before the baseline. `include` copies git-ignored files from this checkout.\n\
# [worktree]\n# prepare = \"auto\" # \"off\": never prepare\n\
# include = [\".env\"] # never committed, secret-named values masked\n"
.to_owned();
}
format!(
"\n# Git-ignored files a run's worktree gets copied from this checkout: their values are\n\
# never committed, and secret-named values masked in what speccoder shows and journals.\n\
{INCLUDE_PROPOSED}\n# [worktree]\n# include = [{}]\n",
include
.iter()
.map(|l| quote(l))
.collect::<Vec<_>>()
.join(", ")
)
}
pub fn activate_include(root: &Path) -> Result<(), String> {
let file = root.join(FILE);
let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let from = format!("{INCLUDE_PROPOSED}\n# [worktree]\n# include = ");
let Some(at) = text.find(&from) else {
return Err("no [worktree] include proposal in the configuration".to_owned());
};
let next = format!(
"{}{}\n[worktree]\ninclude = {}",
&text[..at],
"# the git-ignored .env files here",
&text[at + from.len()..]
);
crate::prepare::parse(&next)?;
crate::verify::config::parse(&next)?;
std::fs::write(&file, next).map_err(|e| format!("{}: {e}", file.display()))
}
fn quote(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_owned())
}
pub const BEDROCK_MODEL: &str = "global.anthropic.claude-sonnet-5-5";
fn model_choice(
provider: Option<&str>,
model: Option<&str>,
env: &dyn Fn(&str) -> Option<String>,
) -> (String, String) {
let set = |v: &str| env(v).is_some_and(|x| !x.is_empty());
let provider = provider.map_or_else(
|| {
if set("ANTHROPIC_API_KEY") {
"anthropic".to_owned()
} else if set("OPENAI_API_KEY") {
"openai".to_owned()
} else if crate::aws::detect(env, &home(env)).is_some() {
"bedrock".to_owned()
} else {
"anthropic".to_owned()
}
},
str::to_owned,
);
let model = model.map_or_else(
|| match provider.as_str() {
"openai" => "gpt-5.4-mini".to_owned(),
"bedrock" => BEDROCK_MODEL.to_owned(),
_ => "claude-sonnet-5-5".to_owned(),
},
str::to_owned,
);
(provider, model)
}
fn home(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
PathBuf::from(env("HOME").unwrap_or_default())
}
#[must_use]
pub fn render(
checks: &[Proposed],
allow: &[Vec<String>],
format: Option<&[String]>,
provider: &str,
model: &str,
region: Option<&str>,
) -> String {
let mut out = String::from(
"# speccoder configuration — written by `speccoder init`; review it, then commit it\n\
# (runs read the committed file in their worktree).\n\n\
# Checks: speccoder runs them before and after every attempt and decides the\n\
# verdict from them. `fast` runs per task; `full` at phase ends and `verify --tier full`.\n",
);
if checks.is_empty() {
out.push_str(
"# No checks were detected. Add at least one, e.g.:\n\
# [checks.test]\n# argv = [\"make\", \"test\"]\n",
);
}
for c in checks {
out.push_str(&format!("\n# {}\n[checks.{}]\nargv = [", c.why, c.name));
out.push_str(
&c.argv
.iter()
.map(|a| quote(a))
.collect::<Vec<_>>()
.join(", "),
);
out.push_str("]\n");
if c.tier != "fast" {
out.push_str(&format!("tier = {}\n", quote(&c.tier)));
}
if let Some(j) = &c.junit {
out.push_str(&format!("junit = {}\n", quote(j)));
}
}
let (about, region) = if provider == "bedrock" {
(
"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(),
region.map_or_else(
|| "# region = \"…\" # required: set it\n".to_owned(),
|r| format!("region = {}\n", quote(r)),
),
)
} else {
(
format!(
"The API key comes only from the environment ({}).",
if provider == "openai" {
"OPENAI_API_KEY"
} else {
"ANTHROPIC_API_KEY"
}
),
String::new(),
)
};
out.push_str(&format!(
"\n# The model. {}\n\
[model]\nprovider = {}\nmodel = {}\n{}\
# price_input_per_mtok = 0.0 # USD per million tokens, for `speccoder report --cost`\n\
# price_output_per_mtok = 0.0\n\
# price_cache_read_per_mtok = 0.0\n\
\n# Limits per attempt, and attempts per task (a failed verdict is retried once).\n\
# [budget]\n# max_turns = 30\n# max_tool_calls = 100\n# max_attempts = 2\n\
\n# Test files that existed before an attempt: \"ask\" before any change (default), or\n\
# \"additions\" to let new tests be added without asking (changed lines still ask).\n\
# [tests]\n# frozen = \"additions\"\n\
\n# Commands run in an OS sandbox: writes only to the worktree, credentials unreadable,\n\
# no network beyond loopback. Give commands that fetch the network, or switch it off.\n\
# [sandbox]\n# network_allow = [[\"cargo\", \"fetch\"]]\n# mode = \"auto\" # \"required\" refuses to run unsandboxed; \"off\"\n\
\n# Commands the OS sandbox confines (writes only here, no network beyond loopback)\n\
# run without asking; \"ask\" asks for each.\n\
[approvals]\ncontained = \"allow\"\n\
\n# Commands the model may run besides the checks, exactly as written; what the\n\
# sandbox confines runs anyway, the rest needs approval.\n",
about,
quote(provider),
quote(model),
region
));
if let Some(argv) = format {
out.push_str(&format!(
"\n# The formatter speccoder runs after each attempt, before the checks.\n[format]\nargv = [{}]\n\n",
argv.iter().map(|a| quote(a)).collect::<Vec<_>>().join(", ")
));
}
if allow.is_empty() {
out.push_str("# [commands]\n# allow = [[\"make\", \"check\"]]\n");
} else {
let lists: Vec<String> = allow
.iter()
.map(|a| {
format!(
"[{}]",
a.iter().map(|x| quote(x)).collect::<Vec<_>>().join(", ")
)
})
.collect();
out.push_str(&format!("[commands]\nallow = [{}]\n", lists.join(", ")));
}
out
}
pub 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";
pub fn init_with(
path: &Path,
provider: Option<&str>,
model: Option<&str>,
region: Option<&str>,
force: bool,
found: Option<Found>,
env: &dyn Fn(&str) -> Option<String>,
) -> CommandResult {
let refuse = |status, m: String| CommandResult::new("init", status, &m);
let root = match crate::inspect(path, None).data() {
Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
_ => path.to_path_buf(),
};
let file = root.join(FILE);
if file.exists() && !force {
return refuse(
CommandStatus::ConfigError,
format!("{} exists already", file.display()),
)
.with_code("CONFIG-EXISTS")
.with_fix(vec![
"speccoder config".to_owned(),
"speccoder init --force".to_owned(),
]);
}
if let Some(p) = provider.filter(|p| !matches!(*p, "anthropic" | "openai" | "bedrock")) {
return refuse(
CommandStatus::UsageError,
format!("--provider {p}: anthropic, openai or bedrock"),
);
}
let (provider, model) = model_choice(provider, model, env);
let bedrock = provider == "bedrock";
if region.is_some() && !bedrock {
return refuse(
CommandStatus::UsageError,
format!("--region is for --provider bedrock, not {provider}"),
);
}
let region = bedrock
.then(|| {
region
.map(|r| r.trim().to_owned())
.filter(|r| !r.is_empty())
.or_else(|| crate::aws::region(env, &home(env)).map(|(r, _)| r))
})
.flatten();
let found = found.unwrap_or_else(|| detect(&root));
let checks = propose(&found);
let mut text = render(
&checks,
&allow(&found),
formatter(&found).as_deref(),
&provider,
&model,
region.as_deref(),
);
let instructions: Vec<String> = ["AGENTS.md", "CLAUDE.md"]
.iter()
.filter(|n| root.join(n).is_file())
.map(|n| (*n).to_owned())
.collect();
text.push_str(&worktree_section(&found.include, found.worktreeinclude));
if instructions == ["CLAUDE.md"] {
text.push_str(
"\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",
);
}
let key = if bedrock {
let source = crate::aws::detect(env, &home(env));
speccoder_core::InitKey {
variable: crate::aws::API_KEY.to_owned(),
set: source.is_some(),
source: source.map(|s| s.describe()),
}
} else {
let variable = if provider == "openai" {
"OPENAI_API_KEY"
} else {
"ANTHROPIC_API_KEY"
}
.to_owned();
speccoder_core::InitKey {
set: env(&variable).is_some_and(|v| !v.is_empty()),
variable,
source: None,
}
};
let model_parsed = crate::substrate::model_config::parse(&text)
.map(|_| ())
.or_else(|e| {
if bedrock && region.is_none() && e.contains("needs `region`") {
Ok(())
} else {
Err(e)
}
});
if let Err(e) = crate::verify::config::parse(&text)
.and(model_parsed)
.and_then(|()| crate::substrate::run_config::parse(&text).map(|_| ()))
{
return refuse(
CommandStatus::Error,
format!("generated configuration is invalid: {e}"),
);
}
let mut wrote = Vec::new();
if let Err(e) = std::fs::write(&file, &text) {
return refuse(CommandStatus::Error, format!("{}: {e}", file.display()));
}
wrote.push(FILE.to_owned());
if checks.iter().any(|c| c.argv.iter().any(|a| a == "nextest")) {
let nextest = root.join(".config/nextest.toml");
let existing = std::fs::read_to_string(&nextest).unwrap_or_default();
if !existing.contains("[profile.speccoder]") {
let _ = std::fs::create_dir_all(root.join(".config"));
if std::fs::write(&nextest, format!("{existing}{NEXTEST_PROFILE}")).is_ok() {
wrote.push(".config/nextest.toml".to_owned());
}
}
}
let mut output: Vec<&str> = Vec::new();
if checks
.iter()
.any(|c| c.argv.iter().any(|a| a.contains(".speccoder/")))
{
output.push(".speccoder/");
}
for c in &checks {
for dir in crate::worktree::build_output(&c.argv) {
if !output.contains(dir) {
output.push(dir);
}
}
}
let git = Git::at(&root);
let missing: Vec<&str> = output
.into_iter()
.filter(|d| {
!git.succeeds(&["check-ignore", "-q", "--no-index", d])
.unwrap_or(false)
})
.collect();
if !missing.is_empty() {
let ignore = root.join(".gitignore");
let existing = std::fs::read_to_string(&ignore).unwrap_or_default();
let clean = !ignore.exists()
|| git
.line(&["status", "--porcelain", "--", ".gitignore"])
.is_ok_and(|l| l.trim().is_empty());
if clean {
let sep = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
if std::fs::write(
&ignore,
format!(
"{existing}{sep}# what checks write (speccoder init)\n{}\n",
missing.join("\n")
),
)
.is_ok()
{
wrote.push(".gitignore".to_owned());
}
}
}
let mut message = if checks.is_empty() {
format!("wrote {FILE} without checks — add one before `speccoder run`")
} else {
format!(
"wrote {} with {} check(s) and {provider}/{model}; review it, then commit it",
speccoder_core::words::listed(&wrote),
checks.len()
)
};
if bedrock && region.is_none() {
message.push_str("; no AWS region was found — speccoder config set model.region <region>");
}
CommandResult::new("init", CommandStatus::Ok, &message).with_data(CommandData::Init(Box::new(
speccoder_core::InitReport {
path: file.display().to_string(),
checks: checks
.iter()
.map(|c| speccoder_core::InitCheck {
name: c.name.clone(),
argv: c.argv.clone(),
tier: c.tier.clone(),
why: c.why.clone(),
})
.collect(),
model: format!("{provider}/{model}"),
wrote,
ran: Vec::new(),
instructions,
key,
region,
committed: None,
include: found.include.clone(),
include_active: false,
worktreeinclude: found.worktreeinclude,
},
)))
}
#[must_use]
pub fn no_tests(argv: &[String], exit: Option<i32>, output: &str) -> bool {
let has = |w: &str| argv.iter().any(|a| a == w || a.ends_with(&format!("/{w}")));
let lower = output.to_lowercase();
if has("pytest") {
return exit == Some(5);
}
if has("go") && argv.iter().any(|a| a == "test") {
let said: Vec<&str> = output
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
return exit == Some(0)
&& !said.is_empty()
&& said
.iter()
.all(|l| l.contains("no test files") || l.contains("matched no packages"));
}
if has("vitest") || has("jest") || argv.iter().any(|a| a == "test") && has("npm") {
return exit == Some(0)
&& (lower.contains("no test files found") || lower.contains("no tests found"));
}
if has("nextest") {
return exit == Some(4) && lower.contains("no tests to run");
}
false
}
#[must_use]
pub fn verify_written(root: &Path) -> Vec<speccoder_core::InitRun> {
use speccoder_core::verify::CheckStatus;
let Ok((runner, _)) = crate::sandbox::runner_at(root) else {
return Vec::new();
};
let Ok(report) =
crate::verify::run_checks(root, Some(crate::verify::config::Tier::Fast), &runner)
else {
return Vec::new();
};
report
.checks
.iter()
.map(|c| speccoder_core::InitRun {
name: c.name.clone(),
passed: matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped),
status: if no_tests(
&c.argv,
c.exit_code,
&format!("{}\n{}", c.stdout_tail, c.stderr_tail),
) {
"no_tests".to_owned()
} else {
serde_json::to_value(c.status)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default()
},
duration_ms: c.duration_ms,
reason: (!matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped))
.then(|| {
let said = |t: &str| {
t.lines()
.rev()
.map(str::trim)
.find(|l| !l.is_empty())
.map(str::to_owned)
};
said(&c.stderr_tail)
.or_else(|| said(&c.stdout_tail))
.or_else(|| c.note.clone())
.unwrap_or_else(|| {
format!(
"exit {}",
c.exit_code
.map_or_else(|| "?".to_owned(), |e| e.to_string())
)
})
})
.map(|r| r.chars().take(200).collect()),
})
.collect()
}
#[must_use]
pub fn init(
path: &Path,
provider: Option<&str>,
model: Option<&str>,
region: Option<&str>,
force: bool,
) -> CommandResult {
init_with(path, provider, model, region, force, None, &crate::aws::var)
}
#[derive(Debug, Clone)]
pub struct Prepared {
pub written: CommandResult,
pub report: speccoder_core::InitReport,
pub root: PathBuf,
pub to_commit: Vec<String>,
pub failed: Vec<String>,
}
pub const COMMITTABLE: &[&str] = &[
FILE,
".config/nextest.toml",
".gitignore",
"Cargo.lock",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"uv.lock",
"poetry.lock",
"go.sum",
];
pub fn prepare(
path: &Path,
provider: Option<&str>,
model: Option<&str>,
region: Option<&str>,
force: bool,
no_verify: bool,
) -> Result<Prepared, Box<CommandResult>> {
let written = init(path, provider, model, region, force);
let Some(CommandData::Init(report)) = written.data() else {
return Err(Box::new(written));
};
let mut report = (**report).clone();
let root = PathBuf::from(&report.path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
if !no_verify && !report.checks.is_empty() {
report.ran = verify_written(&root);
}
let failed = report
.ran
.iter()
.filter(|r| !r.passed && r.status != "no_tests")
.map(|r| r.name.clone())
.collect();
let mut to_commit: Vec<String> = report.wrote.clone();
let profile = ".config/nextest.toml";
if report
.checks
.iter()
.any(|c| c.argv.iter().any(|a| a == "nextest"))
&& root.join(profile).exists()
&& !to_commit.iter().any(|p| p == profile)
&& Git::at(&root)
.line(&["status", "--porcelain", "--", profile])
.is_ok_and(|l| !l.trim().is_empty())
{
to_commit.push(profile.to_owned());
}
for lock in crate::worktree::LOCKFILES {
if root.join(lock).exists()
&& !to_commit.iter().any(|p| p == lock)
&& Git::at(&root)
.line(&["status", "--porcelain", "--", lock])
.is_ok_and(|l| l.starts_with("??"))
{
to_commit.push((*lock).to_owned());
}
}
Ok(Prepared {
written,
report,
root,
to_commit,
failed,
})
}
pub fn commit_written(root: &Path, paths: &[String]) -> Result<String, String> {
commit_written_as(root, paths, "speccoder: config")
}
pub fn commit_written_as(root: &Path, paths: &[String], subject: &str) -> Result<String, String> {
if paths.is_empty() {
return Err("nothing to commit".to_owned());
}
if let Some(p) = paths.iter().find(|p| !COMMITTABLE.contains(&p.as_str())) {
return Err(format!("`{p}` is not a file init commits"));
}
let g = Git::at(root);
let names: Vec<&str> = paths.iter().map(String::as_str).collect();
let mut add = vec!["add", "--"];
add.extend(&names);
let mut commit = vec!["commit", "-q", "-m", subject, "--"];
commit.extend(&names);
g.run(&add).map_err(|e| e.0)?;
let mut unchanged = vec!["diff", "--cached", "--quiet", "--"];
unchanged.extend(&names);
if g.succeeds(&unchanged).unwrap_or(false) {
return Err(format!(
"nothing to commit: {} is committed as it is",
speccoder_core::words::listed(&names)
));
}
g.run(&commit)
.and_then(|_| g.line(&["rev-parse", "--short", "HEAD"]))
.map_err(|e| {
if e.0.trim().is_empty() {
"git commit failed without saying why".to_owned()
} else {
e.0
}
})
}
#[must_use]
pub fn finish(p: Prepared) -> CommandResult {
let Prepared {
written,
report,
to_commit,
failed,
..
} = p;
let mut message = match &report.committed {
Some(id) => written.message().replace(
"; review it, then commit it",
&format!(" and committed it ({id})"),
),
None => written.message().to_owned(),
};
if !failed.is_empty() {
message = format!(
"{message}; the check(s) {} fail on the untouched code: every task would be judged against a red baseline",
failed.join(", ")
);
}
let step = |command: String, why: &str| speccoder_core::next::NextStep {
command,
why: why.to_owned(),
};
let mut next = Vec::new();
let root = PathBuf::from(&report.path)
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let format = crate::format::at(&root);
if format.detect(&root).is_none() {
let (command, why) = format.messages().make_project;
message = format!("{message}; there is no spec project here yet");
next.push(step(command.to_owned(), why));
}
if report.committed.is_none() {
let listed = to_commit.join(" ");
next.push(step(
format!("git add {listed} && git commit -m \"speccoder: config\" -- {listed}"),
"speccoder reads committed files only",
));
}
if !report.key.set && report.model.starts_with("bedrock/") {
next.push(step(
format!("export {}=…", report.key.variable),
"a Bedrock API key — or run aws login where you start speccoder",
));
} else if !report.key.set {
next.push(step(
format!("export {}=…", report.key.variable),
"the model key, from the environment only",
));
}
let features = match crate::current::list(&root, None).data() {
Some(CommandData::Features(f)) => f.features.len(),
_ => 0,
};
if features == 0 {
next.push(step(
"speccoder spec specify \"…\"".to_owned(),
"describe the first feature; its spec, plan and tasks follow",
));
} else {
next.push(step(
"speccoder run".to_owned(),
"work through the ready tasks",
));
}
CommandResult::new("init", CommandStatus::Ok, &message)
.with_data(CommandData::Init(Box::new(report)))
.with_next(next)
}
#[derive(Debug, Clone, Serialize)]
pub struct Preview {
pub configured: bool,
pub checks: Vec<Proposed>,
pub allow: Vec<Vec<String>>,
pub formatter: Option<Vec<String>>,
pub keys: Vec<(String, bool)>,
pub bedrock: BedrockPreview,
pub include: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct BedrockPreview {
pub source: Option<String>,
pub region: Option<String>,
}
#[must_use]
pub fn root_of(path: &Path) -> PathBuf {
match crate::inspect(path, None).data() {
Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
_ => path.to_path_buf(),
}
}
#[must_use]
pub fn preview(path: &Path) -> Preview {
let root = root_of(path);
let found = detect(&root);
let set = |v: &str| std::env::var_os(v).is_some_and(|x| !x.is_empty());
Preview {
configured: root.join(FILE).is_file(),
checks: propose(&found),
allow: allow(&found),
formatter: formatter(&found),
keys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
.iter()
.map(|v| ((*v).to_owned(), set(v)))
.collect(),
bedrock: BedrockPreview {
source: crate::aws::detect_here().map(|s| s.describe()),
region: crate::aws::region_here().map(|(r, _)| r),
},
include: found.include,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_uv_project_runs_its_checks_through_uv_never_an_uncommitted_venv() {
let d = tempfile::tempdir().unwrap();
std::fs::write(d.path().join("pyproject.toml"), "[project]\nname = \"p\"\n").unwrap();
std::fs::write(d.path().join("uv.lock"), "version = 1\n").unwrap();
std::fs::create_dir_all(d.path().join(".venv/bin")).unwrap();
std::fs::write(d.path().join(".venv/bin/python"), "").unwrap();
let found = detect(d.path());
assert_eq!(
found.python_run,
["uv", "run", "--locked", "--offline", "python"]
);
let test = &propose(&found)[0];
assert_eq!(
test.argv.join(" "),
"uv run --locked --offline python -m pytest -q --junitxml=.speccoder/junit.xml"
);
std::fs::remove_file(d.path().join("uv.lock")).unwrap();
assert_eq!(detect(d.path()).python_run, [".venv/bin/python"]);
}
#[test]
fn proposals_follow_what_was_found() {
let rust = propose(&Found {
cargo: true,
nextest: true,
..Found::default()
});
let names: Vec<&str> = rust.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["test", "fmt", "clippy"]);
assert_eq!(
rust[0].junit.as_deref(),
Some("target/nextest/speccoder/junit.xml")
);
assert_eq!(rust[2].tier, "full");
let plain = propose(&Found {
cargo: true,
..Found::default()
});
assert_eq!(plain[0].argv, ["cargo", "test", "--workspace"]);
let node = propose(&Found {
node_manager: Some("pnpm".to_owned()),
npm_scripts: vec!["test".into(), "lint".into(), "build".into()],
..Found::default()
});
let shown: Vec<(String, String)> = node
.iter()
.map(|c| (c.name.clone(), c.argv.join(" ")))
.collect();
assert_eq!(
shown,
[
("test".into(), "pnpm run test".into()),
("lint".into(), "pnpm run lint".into())
]
);
let npm = propose(&Found {
node_manager: Some("npm".to_owned()),
npm_scripts: vec!["test".into()],
..Found::default()
});
assert_eq!(npm[0].argv, ["npm", "run", "test"]);
let py = propose(&Found {
python: true,
ruff: true,
..Found::default()
});
assert_eq!(
py.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
["test", "ruff"]
);
let go = propose(&Found {
go: true,
..Found::default()
});
assert_eq!(go[0].argv, ["go", "test", "./..."]);
let make = propose(&Found {
make_test: true,
..Found::default()
});
assert_eq!(make[0].argv, ["make", "test"]);
assert!(propose(&Found::default()).is_empty());
}
#[test]
fn the_model_follows_the_key_that_is_set() {
let none = |_: &str| None;
let openai = |v: &str| (v == "OPENAI_API_KEY").then(|| "k".to_owned());
assert_eq!(model_choice(None, None, &none).0, "anthropic");
assert_eq!(
model_choice(None, None, &openai),
("openai".to_owned(), "gpt-5.4-mini".to_owned())
);
assert_eq!(
model_choice(Some("anthropic"), Some("m"), &openai),
("anthropic".to_owned(), "m".to_owned())
);
}
#[test]
fn every_rendered_file_parses() {
for found in [
Found {
cargo: true,
nextest: true,
..Found::default()
},
Found {
python: true,
ruff: true,
mypy: true,
go: true,
..Found::default()
},
Found::default(),
] {
let text = render(
&propose(&found),
&allow(&found),
formatter(&found).as_deref(),
"openai",
"gpt-5.4-mini",
None,
);
if found.cargo {
assert!(
text.contains("[format]\nargv = [\"cargo\", \"fmt\", \"--all\"]"),
"{text}"
);
assert_eq!(
crate::substrate::run_config::parse(&text)
.ok()
.and_then(|c| c.format),
Some(vec![
"cargo".to_owned(),
"fmt".to_owned(),
"--all".to_owned()
])
);
}
let checks =
crate::verify::config::parse(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
assert_eq!(checks.len(), propose(&found).len());
assert!(crate::substrate::model_config::parse(&text).is_ok_and(|m| m.is_some()));
assert!(crate::substrate::run_config::parse(&text).is_ok());
}
}
#[test]
fn an_empty_suite_on_a_fresh_scaffold_is_no_tests_yet() {
let argv = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
assert!(no_tests(
&argv(&["uv", "run", "pytest"]),
Some(5),
"no tests ran"
));
assert!(!no_tests(
&argv(&["uv", "run", "pytest"]),
Some(1),
"1 failed"
));
assert!(no_tests(
&argv(&["go", "test", "./..."]),
Some(0),
"go: warning: \"./...\" matched no packages\n"
));
assert!(no_tests(
&argv(&["go", "test", "./..."]),
Some(0),
"? \tx\t[no test files]\n"
));
assert!(!no_tests(
&argv(&["go", "test", "./..."]),
Some(0),
"ok \tx\t0.1s\n"
));
assert!(no_tests(
&argv(&["npm", "test"]),
Some(0),
"No test files found, exiting with code 0"
));
assert!(no_tests(
&argv(&["npx", "vitest", "run"]),
Some(0),
"No test files found"
));
assert!(no_tests(
&argv(&["cargo", "nextest", "run"]),
Some(4),
"error: no tests to run"
));
assert!(!no_tests(
&argv(&["cargo", "test"]),
Some(0),
"test result: ok. 1 passed"
));
}
}