#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use agentplane::model::ModelProvider;
use agentplane::model::fake::FakeProvider;
use serde_json::{Value, json};
use speccoder_core::verify::Verdict;
use speccoder_core::{CommandData, CommandResult, RunReport};
fn git(dir: &Path, args: &[&str]) -> String {
let out = Command::new("git")
.args(["-c", "maintenance.auto=false", "-c", "gc.auto=0"])
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@example.com",
"-c",
"commit.gpgsign=false",
])
.args([
"-c",
"init.defaultBranch=main",
"-c",
"core.hooksPath=/dev/null",
])
.args(args)
.current_dir(dir)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).into_owned()
}
const SPEC: &str = "# Feature Specification: greet\n\n## User Scenarios & Testing\n\n### User Story 1 - Greeting (Priority: P1)\n\nA caller is greeted.\n\n**Acceptance Scenarios**:\n\n1. **Given** a name, **When** greeted, **Then** it returns `Hello, <name>!`.\n2. **Given** an empty name, **When** greeted, **Then** it returns `Hello, world!`.\n";
const TASKS: &str = "# Tasks: greet\n\n## Phase 1: User Story 1 - Greeting (Priority: P1)\n\n- [ ] T001 [US1] Implement `greet` for names in src/lib.rs\n- [ ] T002 [US1] Implement the empty-name default of `greet` in src/lib.rs\n";
const CONFIG: &str = "[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n\n[checks.build]\nargv = [\"cargo\", \"build\", \"--offline\"]\n\n[commands]\nallow_prefix = [[\"cargo\", \"test\"]]\n\n[acceptance]\nheld_out = false\nstrength = false\n";
fn project() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let r = dir.path();
let specify = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../speccoder-speckit/tests/fixtures/graph/.specify");
assert!(
Command::new("cp")
.arg("-R")
.arg(&specify)
.arg(r)
.status()
.unwrap()
.success()
);
for (p, t) in [
(
"Cargo.toml",
"[package]\nname = \"greet\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
),
("src/lib.rs", "pub fn hello() {}\n"),
("specs/001-greet/spec.md", SPEC),
("specs/001-greet/plan.md", "# Plan\n\nRust.\n"),
("specs/001-greet/tasks.md", TASKS),
("speccoder.toml", CONFIG),
(".gitignore", "target\n"),
] {
let f = r.join(p);
fs::create_dir_all(f.parent().unwrap()).unwrap();
fs::write(f, t).unwrap();
}
assert!(
Command::new("cargo")
.args(["generate-lockfile", "--offline"])
.current_dir(r)
.status()
.unwrap()
.success()
);
git(r, &["init", "-q"]);
git(r, &["add", "-A"]);
git(r, &["commit", "-q", "-m", "spec"]);
dir
}
fn report(r: &CommandResult) -> &RunReport {
match r.data() {
Some(CommandData::Run(rep)) => rep,
other => panic!("{} → {other:?}", r.message()),
}
}
fn done(s: &str) -> Value {
json!({ "summary": s, "status": "done", "files_changed": [], "checks_run": [], "notes": [] })
}
fn run(repo: &Path, state: &Path, task: &str, f: Arc<FakeProvider>) -> CommandResult {
let d: Arc<dyn ModelProvider> = f;
speccoder_runtime::run_with(repo, None, task, state, d, None)
}
const T001: &str = "// T001 — US1-1\nuse greet::greet;\n\n#[test]\nfn t001_greets_by_name() {\n assert_eq!(greet(\"Ada\"), \"Hello, Ada!\");\n}\n";
const T002: &str = "\n// T002 — US1-2\n#[test]\nfn t002_greets_the_world() {\n assert_eq!(greet(\"\"), \"Hello, world!\");\n}\n";
fn author(content: &str) -> Value {
json!({
"files": [{ "path": "tests/acceptance_us1.rs", "content": content, "scenarios": ["US1-1"] }],
"argv": ["cargo", "test", "--offline", "--test", "acceptance_us1"],
"notes": []
})
}
#[test]
fn a_story_s_tasks_share_one_acceptance_file_and_both_verify() {
let r = project();
let state = tempfile::tempdir().unwrap();
let f = FakeProvider::new();
f.will_structure(author(T001));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("greet"));
let first = run(r.path(), state.path(), "T001", f.clone());
let rep = report(&first);
assert_eq!(
rep.verdict,
Some(Verdict::Verified),
"{} {:?}",
first.message(),
rep.acceptance
);
let asked = serde_json::to_string(&f.asked()[0].prompt).unwrap();
assert!(
asked.contains("into ONE file: `tests/acceptance_us1.rs`"),
"{asked}"
);
assert!(
asked.contains("Test layout (detected by speccoder"),
"{asked}"
);
let wt = PathBuf::from(&rep.worktree);
assert!(
git(&wt, &["log", "-1", "--format=%B"]).contains("Story-File: tests/acceptance_us1.rs\n")
);
let f = FakeProvider::new();
f.will_structure(author(&format!("{T001}{T002}")));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n if name.is_empty() {\n return \"Hello, world!\".to_owned();\n }\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("default"));
let second = run(r.path(), state.path(), "T002", f.clone());
let rep = report(&second);
assert_eq!(
rep.verdict,
Some(Verdict::Verified),
"{} {:?}",
second.message(),
rep.acceptance
);
let a = rep.acceptance.as_ref().unwrap();
assert_eq!(
(a["appended"].clone(), a["red_class"].clone()),
(json!(true), json!("assertion")),
"{a}"
);
let asked = serde_json::to_string(&f.asked()[0].prompt).unwrap();
assert!(
asked.contains("It already holds the tests of earlier tasks"),
"{asked}"
);
assert!(
asked.contains("t001_greets_by_name"),
"the current content is given: {asked}"
);
let file = fs::read_to_string(wt.join("tests/acceptance_us1.rs")).unwrap();
assert!(file.contains("t001_greets_by_name") && file.contains("t002_greets_the_world"));
assert!(!wt.join("tests/acceptance_t002.rs").exists());
}
#[test]
fn a_section_that_changes_an_existing_line_is_refused() {
let r = project();
let state = tempfile::tempdir().unwrap();
let f = FakeProvider::new();
f.will_structure(author(T001));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("greet"));
let first = run(r.path(), state.path(), "T001", f);
assert_eq!(
report(&first).verdict,
Some(Verdict::Verified),
"{}",
first.message()
);
let f = FakeProvider::new();
f.will_structure(author(
&T001
.replace("\"Ada\"", "\"Bob\"")
.replace("Hello, Ada!", "Hello, Bob!"),
));
f.will_structure(done("nothing"));
let second = run(r.path(), state.path(), "T002", f);
let rep = report(&second);
assert_eq!(
rep.verdict,
Some(Verdict::RegressionOnly),
"{}",
second.message()
);
assert_eq!(
rep.regression.as_ref().unwrap().text,
"its acceptance tests could not be used: changes existing lines of `tests/acceptance_us1.rs`"
);
}
#[test]
fn a_section_that_breaks_the_story_file_is_taken_out_again() {
let r = project();
let state = tempfile::tempdir().unwrap();
let f = FakeProvider::new();
f.will_structure(author(T001));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("greet"));
let first = run(r.path(), state.path(), "T001", f);
assert_eq!(
report(&first).verdict,
Some(Verdict::Verified),
"{}",
first.message()
);
let f = FakeProvider::new();
f.will_structure(author(&format!(
"{T001}\n// T002 — US1-2\n#[test]\nfn t002_greets_the_world( {{\n"
)));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n if name.is_empty() {\n return \"Hello, world!\".to_owned();\n }\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("default"));
let second = run(r.path(), state.path(), "T002", f);
let rep = report(&second);
let a = rep.acceptance.as_ref().unwrap();
assert_eq!(a["removed"], json!(["tests/acceptance_us1.rs"]), "{a}");
assert_eq!(a["files"], json!([]), "{a}");
assert!(
a["note"].as_str().unwrap_or_default().contains("taken out"),
"{a}"
);
let wt = PathBuf::from(&rep.worktree);
assert_eq!(
fs::read_to_string(wt.join("tests/acceptance_us1.rs")).unwrap(),
T001
);
assert!(
rep.verdict.is_some(),
"the attempt was judged on tests that build: {}",
second.message()
);
}
#[test]
fn autotests_off_leaves_no_place_and_writes_no_acceptance_test() {
let r = project();
let manifest = r.path().join("Cargo.toml");
fs::write(
&manifest,
"[package]\nname = \"greet\"\nversion = \"0.1.0\"\nedition = \"2024\"\nautotests = false\n",
)
.unwrap();
git(r.path(), &["commit", "-qam", "no autotests"]);
let state = tempfile::tempdir().unwrap();
let f = FakeProvider::new();
f.will_structure(done("nothing to do"));
let res = run(r.path(), state.path(), "T001", f.clone());
let rep = report(&res);
assert_eq!(
rep.regression.as_ref().map(|r| r.text.as_str()),
Some("its acceptance tests could not be used: no place the runner collects"),
"{}",
res.message()
);
assert_eq!(f.asked().len(), 1);
let checks = speccoder_runtime::doctor::checks(r.path(), None, &|_| None);
let tests = checks
.iter()
.find(|c| c.check == "tests")
.expect("a tests check");
assert!(
tests.detail.contains("autotests = false"),
"{}",
tests.detail
);
}
#[test]
fn a_held_out_file_the_runner_would_not_collect_is_discarded_at_authoring() {
let r = project();
fs::write(
r.path().join("speccoder.toml"),
CONFIG.replace("held_out = false", "held_out = true"),
)
.unwrap();
git(r.path(), &["commit", "-qam", "held out on"]);
let state = tempfile::tempdir().unwrap();
let f = FakeProvider::new();
f.will_structure(author(T001));
f.will_structure(json!({
"files": [{ "path": "tests/held/held_t001.rs", "content": "use greet::greet;\n#[test]\nfn h() { assert_eq!(greet(\"Bo\"), \"Hello, Bo!\"); }\n", "scenarios": ["US1-1"], "argv": ["cargo", "test", "--offline", "--test", "held_t001"], "composition": false }],
"notes": []
}));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("greet"));
let res = run(r.path(), state.path(), "T001", f.clone());
let rep = report(&res);
let h = &rep.acceptance.as_ref().unwrap()["held_out"];
assert_eq!(
(h["state"].clone(), h["reason"].clone()),
(json!("absent"), json!("not_collected")),
"{h}"
);
let asked = serde_json::to_string(&f.asked()[1].prompt).unwrap();
assert!(
asked.contains("Place each file where the runner collects it"),
"{asked}"
);
}
#[test]
fn an_already_passing_section_names_the_task_that_made_it_pass() {
let r = project();
fs::write(
r.path().join("specs/001-greet/tasks.md"),
"# Tasks: greet\n\n## Phase 1: Setup\n\n- [ ] T001 Handle the empty name in src/lib.rs\n\n## Phase 2: User Story 1 - Greeting (Priority: P1)\n\n- [ ] T002 [US1] Implement `greet` for names in src/lib.rs\n- [ ] T003 [US1] Implement the empty-name default of `greet` in src/lib.rs\n",
)
.unwrap();
git(r.path(), &["commit", "-qam", "tasks"]);
let state = tempfile::tempdir().unwrap();
let f = FakeProvider::new();
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n if name.is_empty() {\n return \"Hello, world!\".to_owned();\n }\n String::new()\n}\n" }));
f.will_structure(done("empty"));
let t001 = run(r.path(), state.path(), "T001", f);
assert_eq!(
report(&t001).verdict,
Some(Verdict::RegressionOnly),
"{}",
t001.message()
);
let f = FakeProvider::new();
f.will_structure(author(T001));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n if name.is_empty() {\n return \"Hello, world!\".to_owned();\n }\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("names"));
let t002 = run(r.path(), state.path(), "T002", f);
assert_eq!(
report(&t002).verdict,
Some(Verdict::Verified),
"{}",
t002.message()
);
let f = FakeProvider::new();
f.will_structure(author(&format!("{T001}{T002}")));
f.will_structure(done("nothing to do"));
let t003 = run(r.path(), state.path(), "T003", f);
let rep = report(&t003);
assert_eq!(
rep.verdict,
Some(Verdict::RegressionOnly),
"{} {:?}",
t003.message(),
rep.acceptance
);
assert_eq!(
rep.regression.as_ref().unwrap().text,
"already passing before the attempt — implemented by T001"
);
}
#[test]
fn a_section_may_only_be_appended_and_never_skips_a_test() {
let r = project();
let state = tempfile::tempdir().unwrap();
let f = FakeProvider::new();
f.will_structure(author(T001));
f.will_call_tool("c1", "write_file", json!({ "path": "src/lib.rs", "content": "pub fn greet(name: &str) -> String {\n format!(\"Hello, {name}!\")\n}\n" }));
f.will_structure(done("greet"));
let first = run(r.path(), state.path(), "T001", f);
assert_eq!(
report(&first).verdict,
Some(Verdict::Verified),
"{}",
first.message()
);
let inserted = T001.replace("#[test]\n", "#[test]\n#[ignore]\n");
let f = FakeProvider::new();
f.will_structure(author(&format!("{inserted}{T002}")));
f.will_structure(done("nothing"));
let res = run(r.path(), state.path(), "T002", f);
assert_eq!(
report(&res).regression.as_ref().unwrap().text,
"its acceptance tests could not be used: changes existing lines of `tests/acceptance_us1.rs`"
);
}
#[test]
fn an_appended_section_that_skips_a_test_is_refused() {
let added = "\n// T002\n#[test]\n#[ignore]\nfn t002() {}\n";
assert!(
speccoder_runtime::integrity::appended_section(T001, &format!("{T001}{added}")).is_some()
);
assert!(speccoder_runtime::integrity::has_skip_marker(added));
assert_eq!(
speccoder_runtime::integrity::appended_section(
T001,
&T001.replace("#[test]\n", "#[test]\n#[ignore]\n")
),
None
);
}