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_bin: Option<String>,
34 pub pytest: bool,
36 pub ruff: bool,
38 pub mypy: bool,
40 pub go: bool,
42 pub make_test: bool,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct Proposed {
49 pub name: String,
51 pub argv: Vec<String>,
53 pub tier: String,
55 pub junit: Option<String>,
57 pub why: String,
59}
60
61fn check(name: &str, argv: &[&str], tier: &str, junit: Option<&str>, why: &str) -> Proposed {
62 Proposed {
63 name: name.to_owned(),
64 argv: argv.iter().map(|a| (*a).to_owned()).collect(),
65 tier: tier.to_owned(),
66 junit: junit.map(str::to_owned),
67 why: why.to_owned(),
68 }
69}
70
71#[must_use]
73pub fn propose(found: &Found) -> Vec<Proposed> {
74 let mut out = Vec::new();
75 if found.cargo {
76 out.push(if found.nextest {
77 check(
78 "test",
79 &[
81 "cargo",
82 "nextest",
83 "run",
84 "--workspace",
85 "--profile",
86 "speccoder",
87 "--no-tests=warn",
88 ],
89 "fast",
90 Some("target/nextest/speccoder/junit.xml"),
91 "Cargo.toml; cargo-nextest is installed (its JUnit report names every test)",
92 )
93 } else {
94 check(
95 "test",
96 &["cargo", "test", "--workspace"],
97 "fast",
98 None,
99 "Cargo.toml (install cargo-nextest for per-test results)",
100 )
101 });
102 out.push(check(
103 "fmt",
104 &["cargo", "fmt", "--all", "--check"],
105 "fast",
106 None,
107 "Cargo.toml",
108 ));
109 out.push(check(
110 "clippy",
111 &[
112 "cargo",
113 "clippy",
114 "--workspace",
115 "--all-targets",
116 "--",
117 "-D",
118 "warnings",
119 ],
120 "full",
121 None,
122 "Cargo.toml",
123 ));
124 }
125 if let Some(pm) = &found.node_manager {
126 let has = |s: &str| found.npm_scripts.iter().any(|x| x == s);
127 let run = |script: &str| -> Vec<String> { vec![pm.clone(), "run".into(), script.into()] };
129 let mut add = |name: &str, script: &str, tier: &str| {
130 out.push(Proposed {
131 name: name.to_owned(),
132 argv: run(script),
133 tier: tier.to_owned(),
134 junit: None,
135 why: format!("package.json script `{script}` ({pm})"),
136 });
137 };
138 if has("test") {
139 add(
140 if found.cargo { "node-test" } else { "test" },
141 "test",
142 "fast",
143 );
144 }
145 if has("typecheck") {
146 add("typecheck", "typecheck", "fast");
147 }
148 if has("lint") {
149 add("lint", "lint", "full");
150 }
151 }
152 if found.python {
153 let name = if found.cargo || found.node_manager.is_some() {
154 "pytest"
155 } else {
156 "test"
157 };
158 let python = found.python_bin.as_deref().unwrap_or("python3");
159 let why = if found.pytest {
160 format!("a Python project (pytest, {python})")
161 } else {
162 format!(
163 "a Python project — pytest is not installed for {python}: `{python} -m pip install pytest`"
164 )
165 };
166 out.push(check(
167 name,
168 &[
169 python,
170 "-m",
171 "pytest",
172 "-q",
173 "--junitxml=.speccoder/junit.xml",
174 ],
175 "fast",
176 Some(".speccoder/junit.xml"),
177 &why,
178 ));
179 if found.ruff {
180 out.push(check(
181 "ruff",
182 &["ruff", "check", "."],
183 "fast",
184 None,
185 "ruff is configured",
186 ));
187 }
188 if found.mypy {
189 out.push(check(
190 "mypy",
191 &["mypy", "."],
192 "full",
193 None,
194 "mypy is configured",
195 ));
196 }
197 }
198 if found.go {
199 let name = if out.iter().any(|c| c.name == "test") {
200 "go-test"
201 } else {
202 "test"
203 };
204 out.push(check(
205 name,
206 &["go", "test", "./..."],
207 "fast",
208 None,
209 "go.mod",
210 ));
211 out.push(check(
212 "vet",
213 &["go", "vet", "./..."],
214 "full",
215 None,
216 "go.mod",
217 ));
218 }
219 if out.is_empty() && found.make_test {
220 out.push(check(
221 "test",
222 &["make", "test"],
223 "fast",
224 None,
225 "a Makefile `test` target",
226 ));
227 }
228 out
229}
230
231#[must_use]
234pub fn allow(found: &Found) -> Vec<Vec<String>> {
235 let v = |a: &[&str]| a.iter().map(|x| (*x).to_owned()).collect::<Vec<String>>();
236 let mut out = Vec::new();
237 if found.cargo {
238 out.extend([
239 v(&["cargo", "test"]),
240 v(&["cargo", "check"]),
241 v(&["cargo", "build"]),
242 ]);
243 }
244 if let Some(pm) = &found.node_manager {
245 out.extend([v(&[pm, "test"]), v(&[pm, "run", "test"])]);
246 }
247 if found.python {
248 out.push(v(&[
249 found.python_bin.as_deref().unwrap_or("python3"),
250 "-m",
251 "pytest",
252 ]));
253 }
254 if found.go {
255 out.extend([v(&["go", "test"]), v(&["go", "build"]), v(&["go", "vet"])]);
256 }
257 out
258}
259
260#[must_use]
262pub fn formatter(found: &Found) -> Option<Vec<String>> {
263 let v = |a: &[&str]| Some(a.iter().map(|x| (*x).to_owned()).collect());
264 if found.cargo {
265 v(&["cargo", "fmt", "--all"])
266 } else if found.go {
267 v(&["gofmt", "-w", "."])
268 } else if found.python && found.ruff {
269 v(&["ruff", "format", "."])
270 } else {
271 None
272 }
273}
274
275#[must_use]
277pub fn detect(root: &Path) -> Found {
278 let exists = |p: &str| root.join(p).exists();
279 let read = |p: &str| std::fs::read_to_string(root.join(p)).unwrap_or_default();
280 let cargo = exists("Cargo.toml");
281 let nextest = cargo
282 && std::process::Command::new("cargo")
283 .args(["nextest", "--version"])
284 .stdin(std::process::Stdio::null())
285 .stdout(std::process::Stdio::null())
286 .stderr(std::process::Stdio::null())
287 .status()
288 .is_ok_and(|s| s.success());
289 let (node_manager, npm_scripts) = if exists("package.json") {
290 let manager = if exists("pnpm-lock.yaml") {
291 "pnpm"
292 } else if exists("yarn.lock") {
293 "yarn"
294 } else if exists("bun.lockb") || exists("bun.lock") {
295 "bun"
296 } else {
297 "npm"
298 };
299 let scripts = serde_json::from_str::<serde_json::Value>(&read("package.json"))
300 .ok()
301 .and_then(|v| {
302 v["scripts"]
303 .as_object()
304 .map(|o| o.keys().cloned().collect())
305 })
306 .unwrap_or_default();
307 (Some(manager.to_owned()), scripts)
308 } else {
309 (None, Vec::new())
310 };
311 let pyproject = read("pyproject.toml");
312 let makefile = read("Makefile");
313 let python = ["pyproject.toml", "setup.cfg", "setup.py", "pytest.ini"]
314 .iter()
315 .any(|p| exists(p));
316 let python_bin = [".venv/bin/python", "venv/bin/python"]
318 .iter()
319 .find(|p| python && exists(p))
320 .map(|p| (*p).to_owned());
321 Found {
322 cargo,
323 nextest,
324 npm_scripts,
325 node_manager,
326 python,
327 pytest: python && {
328 let bin = python_bin.as_deref().unwrap_or("python3");
329 std::process::Command::new(if root.join(bin).is_file() {
330 root.join(bin)
331 } else {
332 bin.into()
333 })
334 .args(["-m", "pytest", "--version"])
335 .current_dir(root)
336 .stdin(std::process::Stdio::null())
337 .stdout(std::process::Stdio::null())
338 .stderr(std::process::Stdio::null())
339 .status()
340 .is_ok_and(|s| s.success())
341 },
342 python_bin,
343 ruff: pyproject.contains("[tool.ruff") || exists("ruff.toml") || exists(".ruff.toml"),
344 mypy: pyproject.contains("[tool.mypy") || exists("mypy.ini"),
345 go: exists("go.mod"),
346 make_test: makefile.lines().any(|l| l.starts_with("test:")),
347 }
348}
349
350fn quote(s: &str) -> String {
352 serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_owned())
353}
354
355fn model_choice(
358 provider: Option<&str>,
359 model: Option<&str>,
360 env: &dyn Fn(&str) -> bool,
361) -> (String, String) {
362 let provider = provider.map_or_else(
363 || {
364 if env("ANTHROPIC_API_KEY") || !env("OPENAI_API_KEY") {
365 "anthropic".to_owned()
366 } else {
367 "openai".to_owned()
368 }
369 },
370 str::to_owned,
371 );
372 let model = model.map_or_else(
373 || match provider.as_str() {
374 "openai" => "gpt-5.4-mini".to_owned(),
375 _ => "claude-sonnet-5-5".to_owned(),
376 },
377 str::to_owned,
378 );
379 (provider, model)
380}
381
382#[must_use]
384pub fn render(
385 checks: &[Proposed],
386 allow: &[Vec<String>],
387 format: Option<&[String]>,
388 provider: &str,
389 model: &str,
390) -> String {
391 let mut out = String::from(
392 "# speccoder configuration — written by `speccoder init`; review it, then commit it\n\
393 # (runs read the committed file in their worktree).\n\n\
394 # Checks: speccoder runs them before and after every attempt and decides the\n\
395 # verdict from them. `fast` runs per task; `full` at phase ends and `verify --tier full`.\n",
396 );
397 if checks.is_empty() {
398 out.push_str(
399 "# No checks were detected. Add at least one, e.g.:\n\
400 # [checks.test]\n# argv = [\"make\", \"test\"]\n",
401 );
402 }
403 for c in checks {
404 out.push_str(&format!("\n# {}\n[checks.{}]\nargv = [", c.why, c.name));
405 out.push_str(
406 &c.argv
407 .iter()
408 .map(|a| quote(a))
409 .collect::<Vec<_>>()
410 .join(", "),
411 );
412 out.push_str("]\n");
413 if c.tier != "fast" {
414 out.push_str(&format!("tier = {}\n", quote(&c.tier)));
415 }
416 if let Some(j) = &c.junit {
417 out.push_str(&format!("junit = {}\n", quote(j)));
418 }
419 }
420 out.push_str(&format!(
421 "\n# The model. The API key comes only from the environment ({}).\n\
422 [model]\nprovider = {}\nmodel = {}\n\
423 # price_input_per_mtok = 0.0 # USD per million tokens, for `speccoder report --cost`\n\
424 # price_output_per_mtok = 0.0\n\
425 # price_cache_read_per_mtok = 0.0\n\
426 \n# Limits per attempt, and attempts per task (a failed verdict is retried once).\n\
427 # [budget]\n# max_turns = 30\n# max_tool_calls = 100\n# max_attempts = 2\n\
428 \n# Test files that existed before an attempt: \"ask\" before any change (default), or\n\
429 # \"additions\" to let new tests be added without asking (changed lines still ask).\n\
430 # [tests]\n# frozen = \"additions\"\n\
431 \n# Commands run in an OS sandbox: writes only to the worktree, credentials unreadable,\n\
432 # no network beyond loopback. Give commands that fetch the network, or switch it off.\n\
433 # [sandbox]\n# network_allow = [[\"cargo\", \"fetch\"]]\n# mode = \"auto\" # \"required\" refuses to run unsandboxed; \"off\"\n\
434 \n# Commands the model may run besides the checks (argv prefixes); others need approval.\n",
435 if provider == "openai" {
436 "OPENAI_API_KEY"
437 } else {
438 "ANTHROPIC_API_KEY"
439 },
440 quote(provider),
441 quote(model)
442 ));
443 if let Some(argv) = format {
444 out.push_str(&format!(
445 "\n# The formatter speccoder runs after each attempt, before the checks.\n[format]\nargv = [{}]\n\n",
446 argv.iter().map(|a| quote(a)).collect::<Vec<_>>().join(", ")
447 ));
448 }
449 if allow.is_empty() {
450 out.push_str("# [commands]\n# allow = [[\"make\", \"check\"]]\n");
451 } else {
452 let lists: Vec<String> = allow
453 .iter()
454 .map(|a| {
455 format!(
456 "[{}]",
457 a.iter().map(|x| quote(x)).collect::<Vec<_>>().join(", ")
458 )
459 })
460 .collect();
461 out.push_str(&format!("[commands]\nallow = [{}]\n", lists.join(", ")));
462 }
463 out
464}
465
466pub const NEXTEST_PROFILE: &str = "\n# Written by `speccoder init`: speccoder reads this JUnit report.\n[profile.speccoder]\nfail-fast = false\n\n[profile.speccoder.junit]\npath = \"junit.xml\"\n";
468
469pub fn init_with(
472 path: &Path,
473 provider: Option<&str>,
474 model: Option<&str>,
475 force: bool,
476 found: Option<Found>,
477 env: &dyn Fn(&str) -> bool,
478) -> CommandResult {
479 let refuse = |status, m: String| CommandResult::new("init", status, &m);
480 let root = match crate::inspect(path, None).data() {
482 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
483 _ => path.to_path_buf(),
484 };
485 let file = root.join(FILE);
486 if file.exists() && !force {
487 return refuse(
489 CommandStatus::ConfigError,
490 format!("{} exists already", file.display()),
491 )
492 .with_code("CONFIG-EXISTS")
493 .with_fix(vec![
494 "speccoder config".to_owned(),
495 "speccoder init --force".to_owned(),
496 ]);
497 }
498 if let Some(p) = provider.filter(|p| !matches!(*p, "anthropic" | "openai")) {
499 return refuse(
500 CommandStatus::UsageError,
501 format!("--provider {p}: anthropic or openai"),
502 );
503 }
504 let found = found.unwrap_or_else(|| detect(&root));
505 let checks = propose(&found);
506 let (provider, model) = model_choice(provider, model, env);
507 let mut text = render(
508 &checks,
509 &allow(&found),
510 formatter(&found).as_deref(),
511 &provider,
512 &model,
513 );
514 let instructions: Vec<String> = ["AGENTS.md", "CLAUDE.md"]
517 .iter()
518 .filter(|n| root.join(n).is_file())
519 .map(|n| (*n).to_owned())
520 .collect();
521 if instructions == ["CLAUDE.md"] {
522 text.push_str(
523 "\n# The repository's instructions for agents, given to every attempt: CLAUDE.md\n# here, AGENTS.md wherever a directory has one.\n[context]\ninstructions = [\"AGENTS.md\", \"CLAUDE.md\"]\n",
524 );
525 }
526 let variable = if provider == "openai" {
527 "OPENAI_API_KEY"
528 } else {
529 "ANTHROPIC_API_KEY"
530 }
531 .to_owned();
532 let key = speccoder_core::InitKey {
533 set: env(&variable),
534 variable,
535 };
536 if let Err(e) = crate::verify::config::parse(&text)
538 .and_then(|_| crate::substrate::model_config::parse(&text).map(|_| ()))
539 .and_then(|()| crate::substrate::run_config::parse(&text).map(|_| ()))
540 {
541 return refuse(
542 CommandStatus::Error,
543 format!("generated configuration is invalid: {e}"),
544 );
545 }
546 let mut wrote = Vec::new();
547 if let Err(e) = std::fs::write(&file, &text) {
548 return refuse(CommandStatus::Error, format!("{}: {e}", file.display()));
549 }
550 wrote.push(FILE.to_owned());
551 if checks.iter().any(|c| c.argv.iter().any(|a| a == "nextest")) {
553 let nextest = root.join(".config/nextest.toml");
554 let existing = std::fs::read_to_string(&nextest).unwrap_or_default();
555 if !existing.contains("[profile.speccoder]") {
556 let _ = std::fs::create_dir_all(root.join(".config"));
557 if std::fs::write(&nextest, format!("{existing}{NEXTEST_PROFILE}")).is_ok() {
558 wrote.push(".config/nextest.toml".to_owned());
559 }
560 }
561 }
562 let mut output: Vec<&str> = Vec::new();
568 if checks
569 .iter()
570 .any(|c| c.argv.iter().any(|a| a.contains(".speccoder/")))
571 {
572 output.push(".speccoder/");
573 }
574 for c in &checks {
575 for dir in crate::worktree::build_output(&c.argv) {
576 if !output.contains(dir) {
577 output.push(dir);
578 }
579 }
580 }
581 let git = Git::at(&root);
582 let missing: Vec<&str> = output
583 .into_iter()
584 .filter(|d| {
585 !git.succeeds(&["check-ignore", "-q", "--no-index", d])
586 .unwrap_or(false)
587 })
588 .collect();
589 if !missing.is_empty() {
590 let ignore = root.join(".gitignore");
591 let existing = std::fs::read_to_string(&ignore).unwrap_or_default();
592 let clean = !ignore.exists()
593 || git
594 .line(&["status", "--porcelain", "--", ".gitignore"])
595 .is_ok_and(|l| l.trim().is_empty());
596 if clean {
597 let sep = if existing.is_empty() || existing.ends_with('\n') {
598 ""
599 } else {
600 "\n"
601 };
602 if std::fs::write(
603 &ignore,
604 format!(
605 "{existing}{sep}# what checks write (speccoder init)\n{}\n",
606 missing.join("\n")
607 ),
608 )
609 .is_ok()
610 {
611 wrote.push(".gitignore".to_owned());
612 }
613 }
614 }
615 let message = if checks.is_empty() {
616 format!("wrote {FILE} without checks — add one before `speccoder run`")
617 } else {
618 format!(
619 "wrote {} with {} check(s) and {provider}/{model}; review it, then commit it",
620 speccoder_core::words::listed(&wrote),
621 checks.len()
622 )
623 };
624 CommandResult::new("init", CommandStatus::Ok, &message).with_data(CommandData::Init(Box::new(
625 speccoder_core::InitReport {
626 path: file.display().to_string(),
627 checks: checks
628 .iter()
629 .map(|c| speccoder_core::InitCheck {
630 name: c.name.clone(),
631 argv: c.argv.clone(),
632 tier: c.tier.clone(),
633 why: c.why.clone(),
634 })
635 .collect(),
636 model: format!("{provider}/{model}"),
637 wrote,
638 ran: Vec::new(),
639 instructions,
640 key,
641 committed: None,
642 },
643 )))
644}
645
646#[must_use]
651pub fn no_tests(argv: &[String], exit: Option<i32>, output: &str) -> bool {
652 let has = |w: &str| argv.iter().any(|a| a == w || a.ends_with(&format!("/{w}")));
653 let lower = output.to_lowercase();
654 if has("pytest") {
655 return exit == Some(5);
656 }
657 if has("go") && argv.iter().any(|a| a == "test") {
658 let said: Vec<&str> = output
659 .lines()
660 .map(str::trim)
661 .filter(|l| !l.is_empty())
662 .collect();
663 return exit == Some(0)
664 && !said.is_empty()
665 && said
666 .iter()
667 .all(|l| l.contains("no test files") || l.contains("matched no packages"));
668 }
669 if has("vitest") || has("jest") || argv.iter().any(|a| a == "test") && has("npm") {
672 return exit == Some(0)
673 && (lower.contains("no test files found") || lower.contains("no tests found"));
674 }
675 if has("nextest") {
676 return exit == Some(4) && lower.contains("no tests to run");
678 }
679 false
680}
681
682#[must_use]
685pub fn verify_written(root: &Path) -> Vec<speccoder_core::InitRun> {
686 use speccoder_core::verify::CheckStatus;
687 let Ok((runner, _)) = crate::sandbox::runner_at(root) else {
688 return Vec::new();
689 };
690 let Ok(report) =
691 crate::verify::run_checks(root, Some(crate::verify::config::Tier::Fast), &runner)
692 else {
693 return Vec::new();
694 };
695 report
696 .checks
697 .iter()
698 .map(|c| speccoder_core::InitRun {
699 name: c.name.clone(),
700 passed: matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped),
701 status: if no_tests(
702 &c.argv,
703 c.exit_code,
704 &format!("{}\n{}", c.stdout_tail, c.stderr_tail),
705 ) {
706 "no_tests".to_owned()
707 } else {
708 serde_json::to_value(c.status)
709 .ok()
710 .and_then(|v| v.as_str().map(str::to_owned))
711 .unwrap_or_default()
712 },
713 duration_ms: c.duration_ms,
714 reason: (!matches!(c.status, CheckStatus::Passed | CheckStatus::Skipped))
715 .then(|| {
716 let said = |t: &str| {
717 t.lines()
718 .rev()
719 .map(str::trim)
720 .find(|l| !l.is_empty())
721 .map(str::to_owned)
722 };
723 said(&c.stderr_tail)
724 .or_else(|| said(&c.stdout_tail))
725 .or_else(|| c.note.clone())
726 .unwrap_or_else(|| {
727 format!(
728 "exit {}",
729 c.exit_code
730 .map_or_else(|| "?".to_owned(), |e| e.to_string())
731 )
732 })
733 })
734 .map(|r| r.chars().take(200).collect()),
735 })
736 .collect()
737}
738
739#[must_use]
741pub fn init(
742 path: &Path,
743 provider: Option<&str>,
744 model: Option<&str>,
745 force: bool,
746) -> CommandResult {
747 init_with(path, provider, model, force, None, &|v| {
748 std::env::var_os(v).is_some_and(|x| !x.is_empty())
749 })
750}
751
752#[derive(Debug, Clone)]
755pub struct Prepared {
756 pub written: CommandResult,
758 pub report: speccoder_core::InitReport,
760 pub root: PathBuf,
762 pub to_commit: Vec<String>,
765 pub failed: Vec<String>,
767}
768
769pub const COMMITTABLE: &[&str] = &[
771 FILE,
772 ".config/nextest.toml",
773 ".gitignore",
774 "Cargo.lock",
775 "package-lock.json",
776 "pnpm-lock.yaml",
777 "yarn.lock",
778 "uv.lock",
779 "poetry.lock",
780 "go.sum",
781];
782
783pub fn prepare(
790 path: &Path,
791 provider: Option<&str>,
792 model: Option<&str>,
793 force: bool,
794 no_verify: bool,
795) -> Result<Prepared, Box<CommandResult>> {
796 let written = init(path, provider, model, force);
797 let Some(CommandData::Init(report)) = written.data() else {
798 return Err(Box::new(written));
799 };
800 let mut report = (**report).clone();
801 let root = PathBuf::from(&report.path)
802 .parent()
803 .map(Path::to_path_buf)
804 .unwrap_or_default();
805 if !no_verify && !report.checks.is_empty() {
806 report.ran = verify_written(&root);
807 }
808 let failed = report
809 .ran
810 .iter()
811 .filter(|r| !r.passed && r.status != "no_tests")
812 .map(|r| r.name.clone())
813 .collect();
814 let mut to_commit: Vec<String> = report.wrote.clone();
815 let profile = ".config/nextest.toml";
816 if report
817 .checks
818 .iter()
819 .any(|c| c.argv.iter().any(|a| a == "nextest"))
820 && root.join(profile).exists()
821 && !to_commit.iter().any(|p| p == profile)
822 && Git::at(&root)
823 .line(&["status", "--porcelain", "--", profile])
824 .is_ok_and(|l| !l.trim().is_empty())
825 {
826 to_commit.push(profile.to_owned());
827 }
828 for lock in crate::worktree::LOCKFILES {
831 if root.join(lock).exists()
832 && !to_commit.iter().any(|p| p == lock)
833 && Git::at(&root)
834 .line(&["status", "--porcelain", "--", lock])
835 .is_ok_and(|l| l.starts_with("??"))
836 {
837 to_commit.push((*lock).to_owned());
838 }
839 }
840 Ok(Prepared {
841 written,
842 report,
843 root,
844 to_commit,
845 failed,
846 })
847}
848
849pub fn commit_written(root: &Path, paths: &[String]) -> Result<String, String> {
856 commit_written_as(root, paths, "speccoder: config")
857}
858
859pub fn commit_written_as(root: &Path, paths: &[String], subject: &str) -> Result<String, String> {
865 if paths.is_empty() {
866 return Err("nothing to commit".to_owned());
867 }
868 if let Some(p) = paths.iter().find(|p| !COMMITTABLE.contains(&p.as_str())) {
869 return Err(format!("`{p}` is not a file init commits"));
870 }
871 let g = Git::at(root);
872 let names: Vec<&str> = paths.iter().map(String::as_str).collect();
873 let mut add = vec!["add", "--"];
874 add.extend(&names);
875 let mut commit = vec!["commit", "-q", "-m", subject, "--"];
876 commit.extend(&names);
877 g.run(&add).map_err(|e| e.0)?;
878 let mut unchanged = vec!["diff", "--cached", "--quiet", "--"];
880 unchanged.extend(&names);
881 if g.succeeds(&unchanged).unwrap_or(false) {
882 return Err(format!(
883 "nothing to commit: {} is committed as it is",
884 speccoder_core::words::listed(&names)
885 ));
886 }
887 g.run(&commit)
888 .and_then(|_| g.line(&["rev-parse", "--short", "HEAD"]))
889 .map_err(|e| {
890 if e.0.trim().is_empty() {
891 "git commit failed without saying why".to_owned()
892 } else {
893 e.0
894 }
895 })
896}
897
898#[must_use]
901pub fn finish(p: Prepared) -> CommandResult {
902 let Prepared {
903 written,
904 report,
905 to_commit,
906 failed,
907 ..
908 } = p;
909 let mut message = match &report.committed {
910 Some(id) => written.message().replace(
911 "; review it, then commit it",
912 &format!(" and committed it ({id})"),
913 ),
914 None => written.message().to_owned(),
915 };
916 if !failed.is_empty() {
917 message = format!(
918 "{message}; the check(s) {} fail on the untouched code: every task would be judged against a red baseline",
919 failed.join(", ")
920 );
921 }
922 let step = |command: String, why: &str| speccoder_core::next::NextStep {
923 command,
924 why: why.to_owned(),
925 };
926 let mut next = Vec::new();
927 let root = PathBuf::from(&report.path)
930 .parent()
931 .map(Path::to_path_buf)
932 .unwrap_or_default();
933 let format = crate::format::at(&root);
934 if format.detect(&root).is_none() {
935 let (command, why) = format.messages().make_project;
936 message = format!("{message}; there is no spec project here yet");
937 next.push(step(command.to_owned(), why));
938 }
939 if report.committed.is_none() {
940 let listed = to_commit.join(" ");
941 next.push(step(
942 format!("git add {listed} && git commit -m \"speccoder: config\" -- {listed}"),
943 "speccoder reads committed files only",
944 ));
945 }
946 if !report.key.set {
947 next.push(step(
948 format!("export {}=…", report.key.variable),
949 "the model key, from the environment only",
950 ));
951 }
952 let features = match crate::current::list(&root, None).data() {
954 Some(CommandData::Features(f)) => f.features.len(),
955 _ => 0,
956 };
957 if features == 0 {
958 next.push(step(
959 "speccoder spec specify \"…\"".to_owned(),
960 "describe the first feature; its spec, plan and tasks follow",
961 ));
962 } else {
963 next.push(step(
964 "speccoder run".to_owned(),
965 "work through the ready tasks",
966 ));
967 }
968 CommandResult::new("init", CommandStatus::Ok, &message)
969 .with_data(CommandData::Init(Box::new(report)))
970 .with_next(next)
971}
972
973#[derive(Debug, Clone, Serialize)]
975pub struct Preview {
976 pub configured: bool,
978 pub checks: Vec<Proposed>,
980 pub allow: Vec<Vec<String>>,
982 pub formatter: Option<Vec<String>>,
984 pub keys: Vec<(String, bool)>,
986}
987
988#[must_use]
990pub fn root_of(path: &Path) -> PathBuf {
991 match crate::inspect(path, None).data() {
992 Some(CommandData::Inspect(s)) => PathBuf::from(&s.root),
993 _ => path.to_path_buf(),
994 }
995}
996
997#[must_use]
999pub fn preview(path: &Path) -> Preview {
1000 let root = root_of(path);
1001 let found = detect(&root);
1002 let set = |v: &str| std::env::var_os(v).is_some_and(|x| !x.is_empty());
1003 Preview {
1004 configured: root.join(FILE).is_file(),
1005 checks: propose(&found),
1006 allow: allow(&found),
1007 formatter: formatter(&found),
1008 keys: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
1009 .iter()
1010 .map(|v| ((*v).to_owned(), set(v)))
1011 .collect(),
1012 }
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::*;
1018
1019 #[test]
1020 fn proposals_follow_what_was_found() {
1021 let rust = propose(&Found {
1022 cargo: true,
1023 nextest: true,
1024 ..Found::default()
1025 });
1026 let names: Vec<&str> = rust.iter().map(|c| c.name.as_str()).collect();
1027 assert_eq!(names, ["test", "fmt", "clippy"]);
1028 assert_eq!(
1029 rust[0].junit.as_deref(),
1030 Some("target/nextest/speccoder/junit.xml")
1031 );
1032 assert_eq!(rust[2].tier, "full");
1033 let plain = propose(&Found {
1034 cargo: true,
1035 ..Found::default()
1036 });
1037 assert_eq!(plain[0].argv, ["cargo", "test", "--workspace"]);
1038
1039 let node = propose(&Found {
1040 node_manager: Some("pnpm".to_owned()),
1041 npm_scripts: vec!["test".into(), "lint".into(), "build".into()],
1042 ..Found::default()
1043 });
1044 let shown: Vec<(String, String)> = node
1045 .iter()
1046 .map(|c| (c.name.clone(), c.argv.join(" ")))
1047 .collect();
1048 assert_eq!(
1049 shown,
1050 [
1051 ("test".into(), "pnpm run test".into()),
1052 ("lint".into(), "pnpm run lint".into())
1053 ]
1054 );
1055 let npm = propose(&Found {
1056 node_manager: Some("npm".to_owned()),
1057 npm_scripts: vec!["test".into()],
1058 ..Found::default()
1059 });
1060 assert_eq!(npm[0].argv, ["npm", "run", "test"]);
1061
1062 let py = propose(&Found {
1063 python: true,
1064 ruff: true,
1065 ..Found::default()
1066 });
1067 assert_eq!(
1068 py.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
1069 ["test", "ruff"]
1070 );
1071 let go = propose(&Found {
1072 go: true,
1073 ..Found::default()
1074 });
1075 assert_eq!(go[0].argv, ["go", "test", "./..."]);
1076 let make = propose(&Found {
1077 make_test: true,
1078 ..Found::default()
1079 });
1080 assert_eq!(make[0].argv, ["make", "test"]);
1081 assert!(propose(&Found::default()).is_empty());
1082 }
1083
1084 #[test]
1085 fn the_model_follows_the_key_that_is_set() {
1086 let none = |_: &str| false;
1087 let openai = |v: &str| v == "OPENAI_API_KEY";
1088 assert_eq!(model_choice(None, None, &none).0, "anthropic");
1089 assert_eq!(
1090 model_choice(None, None, &openai),
1091 ("openai".to_owned(), "gpt-5.4-mini".to_owned())
1092 );
1093 assert_eq!(
1094 model_choice(Some("anthropic"), Some("m"), &openai),
1095 ("anthropic".to_owned(), "m".to_owned())
1096 );
1097 }
1098
1099 #[test]
1100 fn every_rendered_file_parses() {
1101 for found in [
1102 Found {
1103 cargo: true,
1104 nextest: true,
1105 ..Found::default()
1106 },
1107 Found {
1108 python: true,
1109 ruff: true,
1110 mypy: true,
1111 go: true,
1112 ..Found::default()
1113 },
1114 Found::default(),
1115 ] {
1116 let text = render(
1117 &propose(&found),
1118 &allow(&found),
1119 formatter(&found).as_deref(),
1120 "openai",
1121 "gpt-5.4-mini",
1122 );
1123 if found.cargo {
1124 assert!(
1125 text.contains("[format]\nargv = [\"cargo\", \"fmt\", \"--all\"]"),
1126 "{text}"
1127 );
1128 assert_eq!(
1129 crate::substrate::run_config::parse(&text)
1130 .ok()
1131 .and_then(|c| c.format),
1132 Some(vec![
1133 "cargo".to_owned(),
1134 "fmt".to_owned(),
1135 "--all".to_owned()
1136 ])
1137 );
1138 }
1139 let checks =
1140 crate::verify::config::parse(&text).unwrap_or_else(|e| panic!("{e}\n{text}"));
1141 assert_eq!(checks.len(), propose(&found).len());
1142 assert!(crate::substrate::model_config::parse(&text).is_ok_and(|m| m.is_some()));
1143 assert!(crate::substrate::run_config::parse(&text).is_ok());
1144 }
1145 }
1146
1147 #[test]
1148 fn an_empty_suite_on_a_fresh_scaffold_is_no_tests_yet() {
1149 let argv = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
1150 assert!(no_tests(
1151 &argv(&["uv", "run", "pytest"]),
1152 Some(5),
1153 "no tests ran"
1154 ));
1155 assert!(!no_tests(
1156 &argv(&["uv", "run", "pytest"]),
1157 Some(1),
1158 "1 failed"
1159 ));
1160 assert!(no_tests(
1161 &argv(&["go", "test", "./..."]),
1162 Some(0),
1163 "go: warning: \"./...\" matched no packages\n"
1164 ));
1165 assert!(no_tests(
1166 &argv(&["go", "test", "./..."]),
1167 Some(0),
1168 "? \tx\t[no test files]\n"
1169 ));
1170 assert!(!no_tests(
1171 &argv(&["go", "test", "./..."]),
1172 Some(0),
1173 "ok \tx\t0.1s\n"
1174 ));
1175 assert!(no_tests(
1176 &argv(&["npm", "test"]),
1177 Some(0),
1178 "No test files found, exiting with code 0"
1179 ));
1180 assert!(no_tests(
1181 &argv(&["npx", "vitest", "run"]),
1182 Some(0),
1183 "No test files found"
1184 ));
1185 assert!(no_tests(
1186 &argv(&["cargo", "nextest", "run"]),
1187 Some(4),
1188 "error: no tests to run"
1189 ));
1190 assert!(!no_tests(
1191 &argv(&["cargo", "test"]),
1192 Some(0),
1193 "test result: ok. 1 passed"
1194 ));
1195 }
1196}