1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
20pub struct Found {
21 pub cargo: bool,
23 pub nextest: bool,
25 pub npm_scripts: Vec<String>,
27 pub node_manager: Option<String>,
29 pub python: bool,
31 pub python_run: Vec<String>,
37 pub pytest: bool,
39 pub ruff: bool,
41 pub mypy: bool,
43 pub go: bool,
45 pub make_test: bool,
47 pub include: Vec<String>,
50 pub worktreeinclude: bool,
52}
53
54impl Found {
55 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
69pub struct Proposed {
70 pub name: String,
72 pub argv: Vec<String>,
74 pub tier: String,
76 pub junit: Option<String>,
78 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#[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 &[
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 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#[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#[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#[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 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#[must_use]
385pub fn env_files(root: &Path) -> Vec<String> {
386 crate::include::matched(root, &[".env".to_owned(), ".env.*".to_owned()])
387}
388
389const INCLUDE_PROPOSED: &str = "# the git-ignored .env files here; to copy them, uncomment:";
392
393fn 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
422pub 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
447fn quote(s: &str) -> String {
449 serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_owned())
450}
451
452pub const BEDROCK_MODEL: &str = "global.anthropic.claude-sonnet-5-5";
455
456fn 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
490fn home(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
492 PathBuf::from(env("HOME").unwrap_or_default())
493}
494
495#[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
603pub 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
606pub 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 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 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 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 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 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 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 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 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#[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 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 return exit == Some(4) && lower.contains("no tests to run");
863 }
864 false
865}
866
867#[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#[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#[derive(Debug, Clone)]
939pub struct Prepared {
940 pub written: CommandResult,
942 pub report: speccoder_core::InitReport,
944 pub root: PathBuf,
946 pub to_commit: Vec<String>,
949 pub failed: Vec<String>,
951}
952
953pub 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
967pub 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 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
1034pub fn commit_written(root: &Path, paths: &[String]) -> Result<String, String> {
1041 commit_written_as(root, paths, "speccoder: config")
1042}
1043
1044pub 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 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#[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 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 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#[derive(Debug, Clone, Serialize)]
1165pub struct Preview {
1166 pub configured: bool,
1168 pub checks: Vec<Proposed>,
1170 pub allow: Vec<Vec<String>>,
1172 pub formatter: Option<Vec<String>>,
1174 pub keys: Vec<(String, bool)>,
1176 pub bedrock: BedrockPreview,
1178 pub include: Vec<String>,
1180}
1181
1182#[derive(Debug, Clone, Default, Serialize)]
1185pub struct BedrockPreview {
1186 pub source: Option<String>,
1188 pub region: Option<String>,
1190}
1191
1192#[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#[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 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}