#![cfg(feature = "bdd")]
use rstest_bdd_macros::{given, scenarios, then, when};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
#[derive(Default)]
struct BddWorld {
exit_code: Option<i32>,
stderr: String,
stdout: String,
success: bool,
env_overrides: HashMap<String, String>,
cwd: Option<PathBuf>,
fixture_dir: Option<TempDir>,
}
thread_local! {
static WORLD: RefCell<Option<BddWorld>> = const { RefCell::new(None) };
}
fn reset_world() {
WORLD.with(|w| *w.borrow_mut() = Some(BddWorld::default()));
}
fn with_world<F, R>(f: F) -> R
where
F: FnOnce(&BddWorld) -> R,
{
WORLD.with(|w| {
let w = w.borrow();
let w = w.as_ref().expect("world not initialized");
f(w)
})
}
fn llman_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_llman"))
}
fn split_args(raw: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
for ch in raw.chars() {
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
c if c.is_whitespace() && !in_single && !in_double => {
if !current.is_empty() {
args.push(std::mem::take(&mut current));
}
}
c => current.push(c),
}
}
if !current.is_empty() {
args.push(current);
}
args
}
fn run_llman(args_raw: &str) {
let (env_overrides, cwd) = WORLD.with(|w| {
let w = w.borrow();
let w = w.as_ref().expect("world not initialized");
(w.env_overrides.clone(), w.cwd.clone())
});
let mut cmd = Command::new(llman_bin());
cmd.args(split_args(args_raw));
cmd.env("LLMAN_CONFIG_DIR", "./artifacts/testing_config_home");
for (k, v) in &env_overrides {
cmd.env(k, v);
}
if let Some(dir) = &cwd {
cmd.current_dir(dir);
}
let output = cmd.output().expect("run llman");
record_output(output);
}
fn run_llman_in(dir: &std::path::Path, args_raw: &str, extra_env: &[(&str, &str)]) {
let mut cmd = Command::new(llman_bin());
cmd.args(split_args(args_raw));
cmd.env("LLMAN_CONFIG_DIR", "./artifacts/testing_config_home");
for (k, v) in extra_env {
cmd.env(k, v);
}
cmd.current_dir(dir);
let output = cmd.output().expect("run llman in fixture");
assert!(
output.status.success(),
"fixture setup command failed: `{args_raw}` in {}\nstdout:\n{}\nstderr:\n{}",
dir.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
#[given("llman 二进制已构建")]
fn given_binary_built() {
reset_world();
assert!(
llman_bin().exists(),
"llman binary not found at {}",
llman_bin().display()
);
}
#[given("{env_var} 为 {value}")]
fn given_env_var(env_var: String, value: String) {
WORLD.with(|w| {
let mut w = w.borrow_mut();
let world = w.as_mut().expect("world not initialized");
world.env_overrides.insert(env_var, value);
});
}
#[given("今目录为 {cwd}")]
fn given_cwd(cwd: PathBuf) {
WORLD.with(|w| {
let mut w = w.borrow_mut();
let world = w.as_mut().expect("world not initialized");
world.cwd = Some(cwd);
});
}
fn seed_bdd_project(mode: &str) {
reset_world();
let temp = TempDir::new().expect("create fixture tempdir");
let dir = temp.path().to_path_buf();
run_llman_in(&dir, "sdd init --lang en", &[]);
write_single_track_spec(&dir, "sample", &[("r1", "R1")]);
let sample_feature = dir.join("llmanspec/specs/sample/sample.feature");
let mut body = std::fs::read_to_string(&sample_feature).expect("read seeded feature");
body.push_str(
"\n @req:r1 @executable\n Scenario: harness-happy\n Given a precondition\n When an action\n Then an outcome\n",
);
std::fs::write(&sample_feature, body).expect("append acceptance scenario");
let change_dir = dir.join("llmanspec/changes/add-scen");
std::fs::create_dir_all(&change_dir).expect("mkdir fixture change");
std::fs::write(
change_dir.join("proposal.md"),
"## Why\nAdd r2 to sample.\n\n## What Changes\n- Add requirement r2.\n",
)
.expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\n").expect("write fixture design");
std::fs::write(change_dir.join("tasks.md"), "- [x] t1\n").expect("write fixture tasks");
let mode_norm = mode.trim().trim_matches('"');
let mut config = "schema: spec-driven\nlocale: en\n".to_string();
if mode_norm == "on" {
config.push_str("\nbdd:\n run_command: \"cargo test --features bdd\"\n");
}
std::fs::write(dir.join("llmanspec/config.yaml"), config).expect("write fixture config");
run_llman_in(&dir, "sdd init --update", &[]);
Command::new("git")
.args(["init", "--quiet", "--initial-branch=main"])
.current_dir(&dir)
.output()
.expect("git init fixture");
Command::new("git")
.args(["config", "user.name", "t"])
.current_dir(&dir)
.output()
.expect("git config user.name");
Command::new("git")
.args(["config", "user.email", "t@x"])
.current_dir(&dir)
.output()
.expect("git config user.email");
Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.output()
.expect("git add fixture");
Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@x",
"commit",
"-qm",
"fixture",
])
.current_dir(&dir)
.output()
.expect("git commit fixture");
WORLD.with(|w| {
let mut w = w.borrow_mut();
let world = w.as_mut().expect("world not initialized");
world.fixture_dir = Some(temp);
world.cwd = Some(dir);
});
}
fn fixture_cwd() -> PathBuf {
WORLD.with(|w| {
w.borrow()
.as_ref()
.expect("world not initialized")
.cwd
.clone()
.expect("fixture cwd missing")
})
}
#[given("已初始化含多个 capability 且无占位符计数 run_command 的 sdd 项目")]
fn given_multi_cap_counter_run_command() {
reset_world();
let temp = TempDir::new().expect("create fixture tempdir");
let dir = temp.path().to_path_buf();
run_llman_in(&dir, "sdd init --lang en", &[]);
for (name, req) in [("sample", "r1"), ("other", "r2")] {
write_single_track_spec(&dir, name, &[(req, name)]);
}
let config = "schema: spec-driven\nlocale: en\n\nbdd:\n run_command: \"printf 'x\\n' >> .bdd-run-count\"\n";
std::fs::write(dir.join("llmanspec/config.yaml"), config).expect("write counter config");
run_llman_in(&dir, "sdd init --update", &[]);
Command::new("git")
.args(["init", "--quiet"])
.current_dir(&dir)
.output()
.expect("git init fixture");
Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.output()
.expect("git add fixture");
Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@x",
"commit",
"-qm",
"fixture",
])
.current_dir(&dir)
.output()
.expect("git commit fixture");
WORLD.with(|w| {
let mut w = w.borrow_mut();
let world = w.as_mut().expect("world not initialized");
world.fixture_dir = Some(temp);
world.cwd = Some(dir);
});
}
fn write_single_track_spec(dir: &std::path::Path, name: &str, reqs: &[(&str, &str)]) {
let spec_dir = dir.join(format!("llmanspec/specs/{name}"));
std::fs::create_dir_all(&spec_dir).expect("mkdir spec dir");
let mut body = format!(
"# language: en\n# capability: {name}\n# purpose: {name}\n# scope: llmanspec/specs/{name}\n\nFeature: {name}\n"
);
for (id, title) in reqs {
body.push_str(&format!(
"\n @req:{id} @human\n Scenario: {title}\n System MUST cover {title}.\n"
));
}
std::fs::write(spec_dir.join(format!("{name}.feature")), body).expect("write feature");
}
#[given("已初始化 sdd 项目且 bdd 配置为 {mode}")]
fn given_seeded_sdd_project(mode: String) {
seed_bdd_project(&mode);
}
#[given("已初始化含跨 spec 重复 req_id 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_global_req_collision(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_single_track_spec(&dir, "other", &[("r1", "Other")]);
}
#[given("已初始化含已占用全局 req_id 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_occupied_req(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_single_track_spec(&dir, "sample", &[("r1", "R1"), ("occupied-id", "Occupied")]);
}
#[given("已初始化含损坏 proposal 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_corrupted_proposal(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
let changes_dir = dir.join("llmanspec/changes/broken");
std::fs::create_dir_all(&changes_dir).expect("create change dir");
std::fs::write(
changes_dir.join("proposal.md"),
"---\ndepends_on: [nonexistent-change]\n---\n\n## Why\nx\n\n## What Changes\n- y\n",
)
.expect("write corrupted proposal");
}
#[given("存在 active change 和 archived change 且含 c123-fix-bug")]
fn given_active_and_archived_changes_with_c123() {
reset_world();
let temp = TempDir::new().expect("create fixture tempdir");
let dir = temp.path().to_path_buf();
run_llman_in(&dir, "sdd init --lang en", &[]);
write_single_track_spec(&dir, "sample", &[("r1", "R1")]);
let active = dir.join("llmanspec/changes/c123-fix-bug");
std::fs::create_dir_all(&active).expect("mkdir active change");
std::fs::write(
active.join("proposal.md"),
"---\ndepends_on: []\n---\n\n## Why\nFix bug c123.\n\n## What Changes\n- Fix it.\n",
)
.expect("write active proposal");
std::fs::write(active.join("design.md"), "# Design\n").expect("write design");
std::fs::write(active.join("tasks.md"), "- [x] t1\n").expect("write tasks");
let archived = dir.join("llmanspec/changes/archive/c9-other");
std::fs::create_dir_all(&archived).expect("mkdir archived change");
std::fs::write(
archived.join("proposal.md"),
"---\ndepends_on: []\n---\n\n## Why\nOld change.\n\n## What Changes\n- Done.\n",
)
.expect("write archived proposal");
std::fs::write(archived.join("design.md"), "# Design\n").expect("write archived design");
std::fs::write(archived.join("tasks.md"), "- [x] t1\n").expect("write archived tasks");
WORLD.with(|w| {
let mut w = w.borrow_mut();
let world = w.as_mut().expect("world not initialized");
world.fixture_dir = Some(temp);
world.cwd = Some(dir);
});
}
fn write_complete_change(dir: &std::path::Path, id: &str) {
let change_dir = dir.join("llmanspec/changes").join(id);
std::fs::create_dir_all(&change_dir).expect("mkdir change");
std::fs::write(
change_dir.join("proposal.md"),
"---\ndepends_on: []\n---\n\n## Why\nWhy.\n\n## What Changes\n- What.\n",
)
.expect("write proposal");
std::fs::write(change_dir.join("design.md"), "# Design\n").expect("write design");
std::fs::write(change_dir.join("tasks.md"), "- [x] t1\n").expect("write tasks");
}
fn write_change_id_config(dir: &std::path::Path, pattern: Option<&str>, template: Option<&str>) {
let path = dir.join("llmanspec/config.yaml");
let mut config = std::fs::read_to_string(&path).expect("read fixture config");
config.push_str("\nchange_id:\n");
if let Some(pattern) = pattern {
config.push_str(&format!(" pattern: '{pattern}'\n"));
}
if let Some(template) = template {
config.push_str(&format!(" template: '{template}'\n"));
}
std::fs::write(&path, config).expect("write change_id config");
}
#[given("已初始化含 change_id pattern 与 archive 形态存量的 sdd 项目且存在违规 active change {id}")]
fn given_sdd_project_change_id_pattern_violation(id: String) {
seed_bdd_project("on");
let dir = fixture_cwd();
write_change_id_config(&dir, Some(r"^c[0-9]+-(add|fix)-[a-z0-9-]+$"), None);
let raw_id = id.trim().trim_matches('"');
write_complete_change(&dir, raw_id);
let archived = dir.join("llmanspec/changes/archive/2026-09-13-c20-legacy");
std::fs::create_dir_all(&archived).expect("mkdir archived");
std::fs::write(
archived.join("proposal.md"),
"---\ndepends_on: []\n---\n\n## Why\nOld.\n\n## What Changes\n- Done.\n",
)
.expect("write archived proposal");
}
#[given("已初始化含 change_id template 的 sdd 项目")]
fn given_sdd_project_change_id_template() {
seed_bdd_project("on");
let dir = fixture_cwd();
write_change_id_config(
&dir,
None,
Some("c{{ llman_sdd_unique_id }}-{{ verb }}-{{ subject }}"),
);
}
#[given("已初始化含 change_id 段且 delayed-changes 深层目录含更大号的 sdd 项目")]
fn given_sdd_project_delayed_changes_deeper_number() {
seed_bdd_project("on");
let dir = fixture_cwd();
write_change_id_config(
&dir,
Some(r"^c[0-9]+-(add|fix)-[a-z0-9-]+$"),
Some("c{{ llman_sdd_unique_id }}-{{ verb }}-{{ subject }}"),
);
let deep = dir.join("llmanspec/delayed-changes/tools/c2620-tool-x");
std::fs::create_dir_all(&deep).expect("mkdir deep delayed change");
}
#[given("已初始化含遗留 spec.toon 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_legacy_toon(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
std::fs::write(
dir.join("llmanspec/specs/sample/spec.toon"),
concat!(
"kind: llman.sdd.spec\n",
"name: \"sample\"\n",
"purpose: \"sample legacy\"\n",
"valid_scope[1]: \"llmanspec/specs/sample\"\n",
"requirements[1]{req_id,title,statement}:\n",
" r1,R1,\"System MUST do X.\"\n",
"scenarios[0]:\n",
),
)
.expect("write legacy toon");
}
#[given("已初始化含仅遗留 spec.toon 的 legacy capability 且 bdd 配置为 {mode}")]
fn given_sdd_project_toon_only_capability(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
let spec_dir = dir.join("llmanspec/specs/legacy");
std::fs::create_dir_all(&spec_dir).expect("mkdir legacy capability");
std::fs::write(
spec_dir.join("spec.toon"),
concat!(
"kind: llman.sdd.spec\n",
"name: \"legacy\"\n",
"purpose: \"legacy notes\"\n",
"valid_scope[1]: \"llmanspec/specs/legacy\"\n",
"requirements[1]{req_id,title,statement}:\n",
" r1,R1,\"System MUST do X.\"\n",
"scenarios[0]:\n",
),
)
.expect("write toon-only spec.toon");
}
#[given("已初始化含遗留 spec.toon 与既有 .feature 的 sample3 capability 且 bdd 配置为 {mode}")]
fn given_sdd_project_toon_with_existing_features(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
let spec_dir = dir.join("llmanspec/specs/sample3");
std::fs::create_dir_all(&spec_dir).expect("mkdir sample3 capability");
std::fs::write(
spec_dir.join("spec.toon"),
concat!(
"kind: llman.sdd.spec\n",
"name: \"sample3\"\n",
"purpose: \"sample3 legacy\"\n",
"valid_scope[1]: \"llmanspec/specs/sample3\"\n",
"requirements[1]{req_id,title,statement}:\n",
" r1,R1,\"System MUST do X.\"\n",
"scenarios[4]{req_id,id,given,when,then,feature}:\n",
" r1,acc-1,\"precondition ready\",\"run llman sdd validate sample3\",\"exit code is zero\",true\n",
" r1,acc-2,\"\",\"run llman sdd status\",\"status shows sample3\",true\n",
" r404,orphan,\"\",\"a trigger\",\"an outcome\",true\n",
" r1,note,\"\",\"\",\"\",false\n",
),
)
.expect("write sample3 spec.toon");
std::fs::write(
spec_dir.join("legacy-acc.feature"),
concat!(
"# language: en\n",
"Feature: sample3 legacy acceptance\n",
" @req:r1 @executable\n",
" Scenario: legacy-acc\n",
" Given seeded\n",
" When noop\n",
" Then ok\n",
),
)
.expect("write sample3 legacy .feature");
}
fn write_flat_spec(dir: &std::path::Path, name: &str, req_id: &str) {
let body = format!(
concat!(
"# language: en\n",
"# capability: {name}\n",
"# purpose: {name}\n",
"# scope: llmanspec\n",
"\n",
"Feature: {name}\n",
"\n",
" @req:{req} @human\n",
" Scenario: {req}\n",
" System MUST cover {name}.\n",
),
name = name,
req = req_id,
);
std::fs::write(dir.join(format!("llmanspec/specs/{name}.feature")), body)
.expect("write flat feature");
}
fn write_dir_spec_file(
dir: &std::path::Path,
cap: &str,
file_name: &str,
req_id: Option<&str>,
scope: &str,
) {
let spec_dir = dir.join(format!("llmanspec/specs/{cap}"));
std::fs::create_dir_all(&spec_dir).expect("mkdir spec dir");
let mut body = format!(
"# language: en\n# capability: {cap}\n# purpose: {cap}\n# scope: {scope}\n\nFeature: {cap}\n"
);
if let Some(req) = req_id {
body.push_str(&format!(
"\n @req:{req} @human\n Scenario: {req}\n System MUST cover {cap}.\n"
));
}
std::fs::write(spec_dir.join(file_name), body).expect("write dir feature");
}
#[given("已初始化含扁平 capability 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_flat_capability(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_flat_spec(&dir, "flatcap", "r3");
}
#[given("已初始化含同 id 扁平与目录冲突的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_flat_dir_conflict(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_flat_spec(&dir, "foo", "r3");
write_dir_spec_file(&dir, "foo", "foo.feature", Some("r4"), "llmanspec");
}
#[given("已初始化含多 .feature 目录 capability 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_multi_feature_dir(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_dir_spec_file(&dir, "multi", "multi.feature", Some("r3"), "llmanspec");
write_dir_spec_file(&dir, "multi", "draft.feature", None, "llmanspec");
}
#[given("已初始化含异名单文件目录 capability 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_single_foreign_named_dir(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_dir_spec_file(&dir, "solocap", "inner.feature", Some("r4"), "llmanspec");
}
#[given("已初始化含自引用 scope 单文件目录的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_self_scope_single_dir(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_dir_spec_file(
&dir,
"scoped",
"scoped.feature",
Some("r5"),
"llmanspec/specs/scoped",
);
}
#[given("已初始化含五类不可扁平目录的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_five_skip_classes(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
write_flat_spec(&dir, "foo", "r3");
write_dir_spec_file(&dir, "foo", "foo.feature", Some("r4"), "llmanspec");
write_dir_spec_file(&dir, "legacy", "keep.feature", Some("r6"), "llmanspec");
std::fs::write(
dir.join("llmanspec/specs/legacy/spec.toon"),
"kind: llman.sdd.spec\nname: \"legacy\"\n",
)
.expect("write legacy toon");
write_dir_spec_file(&dir, "multiskip", "a.feature", Some("r7"), "llmanspec");
write_dir_spec_file(&dir, "multiskip", "b.feature", None, "llmanspec");
write_dir_spec_file(&dir, "auxdir", "aux.feature", Some("r8"), "llmanspec");
std::fs::write(dir.join("llmanspec/specs/auxdir/notes.md"), "notes\n").expect("write aux file");
write_dir_spec_file(&dir, "bar", "other.feature", Some("r9"), "llmanspec");
}
#[given("已初始化含无效 @req 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_bad_req(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
let path = dir.join("llmanspec/specs/sample/sample.feature");
let body = std::fs::read_to_string(&path).expect("read sample feature");
let updated = body.replace("@req:r1 @executable", "@req:r999 @executable");
std::fs::write(&path, updated).expect("write dangling @req feature");
}
#[given("项目中存在技能目录 {name}")]
fn given_skill_dir(name: String) {
let dir = fixture_cwd();
let skill_dir = dir
.join(".agents/skills")
.join(name.trim().trim_matches('"'));
std::fs::create_dir_all(&skill_dir).expect("mkdir planted skill");
std::fs::write(skill_dir.join("SKILL.md"), "planted\n").expect("write planted skill");
}
#[given("全局 config.yaml 含 {kind} skills 配置")]
fn given_global_skills_config(kind: String) {
reset_world();
let temp = TempDir::new().expect("create skills-config tempdir");
let dir = temp.path().to_path_buf();
let skills_yaml = match kind.trim() {
"multi-repo" => {
"skills:\n repo:\n - name: Team\n path: /tmp/team-skills\n - path: /tmp/personal-skills\n"
.to_string()
}
"legacy-dir" => "skills:\n dir: /tmp/skills\n".to_string(),
"missing-path" => {
let present = dir.join("present-skills");
std::fs::create_dir_all(&present).expect("create present skills dir");
let missing = dir.join("missing-skills");
format!(
"skills:\n repo:\n - name: gone\n path: {}\n - name: ok\n path: {}\n",
missing.display(),
present.display()
)
}
other => panic!("unknown skills config kind: {other}"),
};
let config = format!("version: \"0.1\"\ntools: {{}}\n{skills_yaml}");
std::fs::write(dir.join("config.yaml"), config).expect("write global config");
WORLD.with(|w| {
let mut guard = w.borrow_mut();
let world = guard.as_mut().expect("world not initialized");
world.fixture_dir = Some(temp);
world.env_overrides.insert(
"LLMAN_CONFIG_DIR".to_string(),
dir.to_string_lossy().to_string(),
);
});
}
#[given("项目 extra_skills 包含 {name}")]
fn given_extra_skills(name: String) {
let dir = fixture_cwd();
let skill = name.trim().trim_matches('"');
let config_path = dir.join("llmanspec/config.yaml");
let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
let bdd_tail = existing
.find("\nbdd:")
.map(|i| existing[i + 1..].to_string())
.or_else(|| {
if existing.starts_with("bdd:") {
Some(existing.clone())
} else {
None
}
});
let mut config = format!("schema: spec-driven\nlocale: en\nextra_skills:\n - {skill}\n");
if let Some(bdd) = bdd_tail {
if let Some(rest) = bdd.strip_prefix("bdd:") {
config.push_str("\nbdd:");
config.push_str(rest);
} else if bdd.starts_with("bdd:") {
config.push('\n');
config.push_str(&bdd);
}
}
std::fs::write(&config_path, config).expect("write extra_skills config");
run_llman_in(&dir, "sdd init --update", &[]);
}
#[given("已初始化含失效 scope 路径 spec 的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_missing_scope_path(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
let sample = dir.join("llmanspec/specs/sample/sample.feature");
let body = std::fs::read_to_string(&sample).expect("read seeded feature");
let body = body.replace("# scope: llmanspec/specs/sample", "# scope: docs/gone");
std::fs::write(&sample, body).expect("rewrite scope header to missing path");
}
#[given("变更 {change} 仅含 proposal")]
fn given_change_proposal_only(change: String) {
let dir = fixture_cwd();
let change_dir = dir
.join("llmanspec/changes")
.join(change.trim().trim_matches('"'));
std::fs::create_dir_all(&change_dir).expect("mkdir draft fixture change");
std::fs::write(
change_dir.join("proposal.md"),
"---\ndepends_on: []\n---\n\n## Why\nr93 draft fixture.\n\n## What Changes\n- Probe determine_stage.\n",
)
.expect("write fixture proposal");
}
#[given("变更 {change} 含 proposal 与 design 不含 tasks")]
fn given_change_proposal_and_design(change: String) {
let dir = fixture_cwd();
let change_dir = dir
.join("llmanspec/changes")
.join(change.trim().trim_matches('"'));
std::fs::create_dir_all(&change_dir).expect("mkdir designed fixture change");
std::fs::write(
change_dir.join("proposal.md"),
"---\ndepends_on: []\n---\n\n## Why\nr93 designed fixture.\n\n## What Changes\n- Probe determine_stage.\n",
)
.expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\nfixture.\n").expect("write design");
}
#[given("变更 {change} 绑定于已提交 specs 改动的分支")]
fn given_change_bound_with_landed_specs(change: String) {
let dir = fixture_cwd();
let change_dir = dir
.join("llmanspec/changes")
.join(change.trim().trim_matches('"'));
std::fs::create_dir_all(&change_dir).expect("mkdir landed fixture change");
let change_id = change.trim().trim_matches('"');
std::fs::write(
change_dir.join("proposal.md"),
format!("---\ndepends_on: []\nbranch: feat/{change_id}\nbase_sha: 0000000000000000000000000000000000000000\n---\n\n## Why\nr1 landed fixture.\n\n## What Changes\n- Add a rule.\n"),
)
.expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\nfixture.\n").expect("write design");
std::fs::write(change_dir.join("tasks.md"), "- [x] t1\n").expect("write tasks");
run_fixture_git(
&dir,
&["checkout", "-q", "-b", &format!("feat/{change_id}")],
);
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(
&dir,
&["commit", "-qm", &format!("fixture change {change_id}")],
);
let head = current_fixture_head(&dir);
let proposal_path = change_dir.join("proposal.md");
let proposal_body = std::fs::read_to_string(&proposal_path).expect("read fixture proposal");
std::fs::write(
&proposal_path,
proposal_body.replace("0000000000000000000000000000000000000000", &head),
)
.expect("rewrite fixture base_sha");
let feature_path = dir.join("llmanspec/specs/sample/sample.feature");
let mut body = std::fs::read_to_string(&feature_path).expect("read seeded feature");
body.push_str("\n @req:r99 @human\n Scenario: R99\n System MUST cover R99.\n");
std::fs::write(&feature_path, body).expect("append new locked rule");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "specs landing"]);
}
#[given("存在可收尾的完整 change {change}")]
fn given_finalizable_change(change: String) {
let dir = fixture_cwd();
let change_dir = dir
.join("llmanspec/changes")
.join(change.trim().trim_matches('"'));
std::fs::create_dir_all(&change_dir).expect("mkdir finalize fixture change");
let change_id = change.trim().trim_matches('"');
std::fs::write(
change_dir.join("proposal.md"),
format!("---\ndepends_on: []\nbranch: feat/{change_id}\nbase_sha: 0000000000000000000000000000000000000000\n---\n\n## Why\nr25 finalize fixture.\n\n## What Changes\n- Probe auto commit.\n"),
)
.expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\nfixture.\n").expect("write design");
std::fs::write(change_dir.join("tasks.md"), "- [x] t1\n").expect("write tasks");
run_fixture_git(
&dir,
&["checkout", "-q", "-b", &format!("feat/{change_id}")],
);
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(
&dir,
&["commit", "-qm", &format!("fixture change {change_id}")],
);
let head = current_fixture_head(&dir);
let proposal_path = change_dir.join("proposal.md");
let proposal_body = std::fs::read_to_string(&proposal_path).expect("read fixture proposal");
std::fs::write(
&proposal_path,
proposal_body.replace("0000000000000000000000000000000000000000", &head),
)
.expect("rewrite fixture base_sha");
let feature_path = dir.join("llmanspec/specs/sample/sample.feature");
let mut body = std::fs::read_to_string(&feature_path).expect("read seeded feature");
body.push_str("\n @req:r98 @human\n Scenario: R98\n System MUST cover R98.\n");
std::fs::write(&feature_path, body).expect("append new locked rule");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "specs landing"]);
}
#[given("已初始化含单独 @agent 场景的 sdd 项目且 bdd 配置为 {mode}")]
fn given_sdd_project_lone_agent(mode: String) {
seed_bdd_project(&mode);
let dir = fixture_cwd();
let feature_path = dir.join("llmanspec/specs/sample/sample.feature");
let mut body = std::fs::read_to_string(&feature_path).expect("read seeded feature");
body.push_str("\n @req:r97 @agent\n Scenario: AgentAlone\n System MUST do A.\n");
std::fs::write(&feature_path, body).expect("append lone @agent scenario");
}
#[given("变更 {change} 绑定且编辑了规则 {kind}")]
fn given_change_bound_editing_locked_rule(change: String, _kind: String) {
let dir = fixture_cwd();
let change_dir = dir
.join("llmanspec/changes")
.join(change.trim().trim_matches('"'));
std::fs::create_dir_all(&change_dir).expect("mkdir rule-edit fixture change");
let change_id = change.trim().trim_matches('"');
std::fs::write(
change_dir.join("proposal.md"),
format!("---\ndepends_on: []\nbranch: feat/{change_id}\nbase_sha: 0000000000000000000000000000000000000000\n---\n\n## Why\nr135 fixture.\n\n## What Changes\n- Edit a locked rule.\n"),
)
.expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\nfixture.\n").expect("write design");
std::fs::write(change_dir.join("tasks.md"), "- [x] t1\n").expect("write tasks");
run_fixture_git(
&dir,
&["checkout", "-q", "-b", &format!("feat/{change_id}")],
);
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(
&dir,
&["commit", "-qm", &format!("fixture change {change_id}")],
);
let head = current_fixture_head(&dir);
let proposal_path = change_dir.join("proposal.md");
let proposal_body = std::fs::read_to_string(&proposal_path).expect("read fixture proposal");
std::fs::write(
&proposal_path,
proposal_body.replace("0000000000000000000000000000000000000000", &head),
)
.expect("rewrite fixture base_sha");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "binding"]);
let feature_path = dir.join("llmanspec/specs/sample/sample.feature");
fixture_edit_r1(&dir, "(edited by r135 fixture)");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "edit locked rule"]);
}
#[given("变更 {change} 绑定且删除了重复 @req 锁定场景之一")]
fn given_change_bound_deleting_duplicate_req_rule(change: String) {
let dir = fixture_cwd();
let change_id = change.trim().trim_matches('"');
let feature_path = dir.join("llmanspec/specs/sample/sample.feature");
let dup_block = "\n @req:r1 @human\n Scenario: R1b\n System MUST cover R1 differently.\n";
let mut body = std::fs::read_to_string(&feature_path).expect("read seeded feature");
body.push_str(dup_block);
std::fs::write(&feature_path, body).expect("append duplicate r1 rule");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "seed duplicate r1 rule"]);
let change_dir = dir.join("llmanspec/changes").join(change_id);
std::fs::create_dir_all(&change_dir).expect("mkdir dup-delete fixture change");
std::fs::write(
change_dir.join("proposal.md"),
format!(
"---\ndepends_on: []\nbranch: feat/{change_id}\nbase_sha: 0000000000000000000000000000000000000000\n---\n\n## Why\nr135 duplicate-req fixture.\n\n## What Changes\n- Delete a duplicate locked rule.\n"
),
)
.expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\nfixture.\n").expect("write design");
std::fs::write(change_dir.join("tasks.md"), "- [x] t1\n").expect("write tasks");
run_fixture_git(
&dir,
&["checkout", "-q", "-b", &format!("feat/{change_id}")],
);
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(
&dir,
&["commit", "-qm", &format!("fixture change {change_id}")],
);
let head = current_fixture_head(&dir);
let proposal_path = change_dir.join("proposal.md");
let proposal_body = std::fs::read_to_string(&proposal_path).expect("read fixture proposal");
std::fs::write(
&proposal_path,
proposal_body.replace("0000000000000000000000000000000000000000", &head),
)
.expect("rewrite fixture base_sha");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "binding"]);
let body = std::fs::read_to_string(&feature_path).expect("read feature for delete");
let deleted = body.replace(dup_block, "");
assert!(deleted != body, "duplicate scenario block must be present");
std::fs::write(&feature_path, deleted).expect("delete duplicate r1 rule");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "delete duplicate locked rule"]);
}
#[given("变更 {change} 含 proposal design tasks 且 attach 状态为 {attached}")]
fn given_change_with_artifacts_and_attach(change: String, attached: String) {
let dir = fixture_cwd();
let change_dir = dir.join("llmanspec/changes").join(&change);
std::fs::create_dir_all(&change_dir).expect("mkdir attach-stage fixture change");
let attach_flag = attached.trim().trim_matches('"');
let bound = matches!(attach_flag, "yes" | "true" | "attached" | "on" | "skip");
let base_sha_placeholder = "0000000000000000000000000000000000000000";
let frontmatter = match attach_flag {
"yes" | "true" | "attached" | "on" => {
format!(
"---\ndepends_on: []\nbranch: feat/{change}\nbase_sha: {base_sha_placeholder}\n---\n"
)
}
"skip" => {
format!(
"---\ndepends_on: []\nbranch: feat/{change}\nbase_sha: {base_sha_placeholder}\nneeds_specs_change: false\n---\n"
)
}
_ => "---\ndepends_on: []\n---\n".to_string(),
};
let proposal = format!(
"{frontmatter}\n## Why\nr93 stage fixture.\n\n## What Changes\n- Probe determine_stage.\n"
);
std::fs::write(change_dir.join("proposal.md"), proposal).expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\nr93 fixture.\n").expect("write design");
let tasks = if attach_flag == "skip" {
"- [x] t1\n"
} else {
"- [ ] t1\n"
};
std::fs::write(change_dir.join("tasks.md"), tasks).expect("write tasks");
if bound {
run_fixture_git(&dir, &["checkout", "-q", "-b", &format!("feat/{change}")]);
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(
&dir,
&["commit", "-qm", &format!("fixture change {change}")],
);
let head = current_fixture_head(&dir);
let proposal_path = change_dir.join("proposal.md");
let proposal_body = std::fs::read_to_string(&proposal_path).expect("read fixture proposal");
std::fs::write(
&proposal_path,
proposal_body.replace(base_sha_placeholder, &head),
)
.expect("rewrite fixture base_sha");
if attach_flag == "skip" {
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "fixture skip binding"]);
}
}
}
fn current_fixture_head(dir: &std::path::Path) -> String {
String::from_utf8_lossy(
&Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.expect("git rev-parse fixture")
.stdout,
)
.trim()
.to_string()
}
#[given("变更 {change} 绑定于先前规则编辑已合入默认分支零推送的历史")]
fn given_change_bound_after_prior_rule_merge(change: String) {
let dir = fixture_cwd();
let default = String::from_utf8_lossy(
&Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(&dir)
.output()
.expect("git branch name fixture")
.stdout,
)
.trim()
.to_string();
run_fixture_git(&dir, &["checkout", "-q", "-b", "feat/prev"]);
fixture_edit_r1(&dir, "(v2 by previous change)");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "prev-rule-edit"]);
run_fixture_git(&dir, &["checkout", "-q", &default]);
run_fixture_git(&dir, &["merge", "-q", "--ff-only", "feat/prev"]);
let base = current_fixture_head(&dir);
let change_id = change.trim().trim_matches('"');
run_fixture_git(
&dir,
&["checkout", "-q", "-b", &format!("feat/{change_id}")],
);
let change_dir = dir.join("llmanspec/changes").join(change_id);
std::fs::create_dir_all(&change_dir).expect("mkdir acc fixture change");
std::fs::write(
change_dir.join("proposal.md"),
format!(
"---\ndepends_on: []\nbranch: feat/{change_id}\nbase_sha: {base}\nneeds_specs_change: false\n---\n\n## Why\naccumulation-immunity fixture.\n\n## What Changes\n- No rule edits (prior edits already merged).\n"
),
)
.expect("write fixture proposal");
std::fs::write(change_dir.join("design.md"), "# Design\nfixture.\n").expect("write design");
std::fs::write(change_dir.join("tasks.md"), "- [x] t1\n").expect("write tasks");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "acc-next docs"]);
run_fixture_git(&dir, &["checkout", "-q", &default]);
fixture_edit_r1(&dir, "(v3 on default)");
run_fixture_git(&dir, &["add", "-A"]);
run_fixture_git(&dir, &["commit", "-qm", "later-rule-edit"]);
run_fixture_git(&dir, &["checkout", "-q", &format!("feat/{change_id}")]);
run_fixture_git(&dir, &["merge", "-q", "--no-edit", &default]);
}
fn fixture_edit_r1(dir: &std::path::Path, suffix: &str) {
let feature_path = dir.join("llmanspec/specs/sample/sample.feature");
let mut body = std::fs::read_to_string(&feature_path).expect("read seeded feature");
body = body.replace(
"System MUST cover R1",
&format!("System MUST cover R1 {suffix}"),
);
std::fs::write(&feature_path, body).expect("rewrite locked rule");
}
fn run_fixture_git(dir: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.args(["-c", "user.name=t", "-c", "user.email=t@x"])
.args(args)
.current_dir(dir)
.status()
.expect("git command runs");
assert!(status.success(), "git {args:?} failed in fixture");
}
#[when("运行 llman {args}")]
fn when_run_llman(args: String) {
run_llman(&args);
}
#[when("在非交互终端运行 llman {args}")]
fn when_run_llman_noninteractive(args: String) {
run_llman(&args);
}
#[when("用前缀 c123 运行 llman {args}")]
fn when_run_llman_prefix_c123(args: String) {
run_llman(&args);
}
#[when("用前缀运行 llman {args}")]
fn when_run_llman_any_prefix(args: String) {
run_llman(&args);
}
#[then("退出码为 {code:i32}")]
fn then_exit_code(code: i32) {
with_world(|w| {
let actual = w.exit_code.unwrap_or(-1);
assert_eq!(actual, code, "expected exit code {code}, got {actual}");
});
}
#[then("退出码非零")]
fn then_exit_nonzero() {
with_world(|w| {
assert!(
!w.success,
"expected non-zero exit code, got success (exit {:?})",
w.exit_code
);
});
}
#[then("退出码为零")]
fn then_exit_zero() {
with_world(|w| {
assert!(
w.success,
"expected zero exit code, got failure (exit {:?})\nstdout:\n{}\nstderr:\n{}",
w.exit_code, w.stdout, w.stderr
);
});
}
#[then("退出码非零且 stderr 包含 {text}")]
fn then_exit_nonzero_and_stderr_contains(text: String) {
then_exit_nonzero();
then_stderr_contains(text);
}
#[then("退出码为零且 stdout 为合法 JSON 且含 JSON 键 {key}")]
fn then_exit_zero_json_key(key: String) {
then_exit_zero();
then_stdout_is_json();
then_stdout_has_json_key(key);
}
#[then("退出码为零且 stdout 为合法 JSON 且含 JSON 键 reqId 且含 JSON 键 capability")]
fn then_exit_zero_json_reqid_and_capability() {
then_exit_zero();
then_stdout_is_json();
then_stdout_has_json_key("reqId".into());
then_stdout_has_json_key("capability".into());
}
#[then("stdout 包含 {text}")]
fn then_stdout_contains(text: String) {
with_world(|w| {
assert!(
w.stdout.contains(&text),
"expected stdout to contain {:?}, got: {}",
text,
w.stdout
);
});
}
#[then("对应的完整 change 被找到且输出正确")]
fn then_prefix_resolved_correctly() {
with_world(|w| {
assert!(
w.success,
"prefix run should succeed, exit {:?}",
w.exit_code
);
let combined = format!("{}\n{}", w.stdout, w.stderr);
assert!(
combined.contains("c123-fix-bug"),
"expected output to mention the resolved change, got: {combined}"
);
});
}
#[then("stderr 包含 {text}")]
fn then_stderr_contains(text: String) {
with_world(|w| {
assert!(
w.stderr.contains(&text),
"expected stderr to contain {:?}, got: {}",
text,
w.stderr
);
});
}
#[then("stdout 不含 {text}")]
fn then_stdout_not_contains(text: String) {
with_world(|w| {
assert!(
!w.stdout.contains(&text),
"expected stdout to NOT contain {:?}, got: {}",
text,
w.stdout
);
});
}
#[then("stderr 不含 {text}")]
fn then_stderr_not_contains(text: String) {
with_world(|w| {
assert!(
!w.stderr.contains(&text),
"expected stderr to NOT contain {:?}, got: {}",
text,
w.stderr
);
});
}
#[then("stdout 为合法 JSON")]
fn then_stdout_is_json() {
with_world(|w| {
serde_json::from_str::<serde_json::Value>(&w.stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON: {e}\n{}", w.stdout));
});
}
#[then("stdout 为合法 JSON 且含 JSON 键 {key}")]
fn then_stdout_is_json_with_key(key: String) {
with_world(|w| {
let v: serde_json::Value = serde_json::from_str(&w.stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON: {e}\n{}", w.stdout));
assert!(
v.get(&key).is_some(),
"JSON key `{key}` missing\n{}",
w.stdout
);
});
}
#[then("stdout 的 JSON 键 {key} 为数字")]
fn then_stdout_json_key_is_number(key: String) {
with_world(|w| {
let v: serde_json::Value = serde_json::from_str(&w.stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON: {e}\n{}", w.stdout));
let mut cur = &v;
for part in key.split('.') {
cur = if let Some(arr) = cur.as_array() {
let idx: usize = part.parse().unwrap_or_else(|_| {
panic!("JSON path `{key}`: segment `{part}` is not an array index")
});
arr.get(idx)
.unwrap_or_else(|| panic!("JSON index `{key}` out of bounds\n{}", w.stdout))
} else {
cur.get(part)
.unwrap_or_else(|| panic!("JSON key `{key}` missing\n{}", w.stdout))
};
}
assert!(
cur.is_number(),
"JSON key `{key}` is not a number\n{}",
w.stdout
);
});
}
#[then("stdout 含 JSON 键 {key}")]
fn then_stdout_has_json_key(key: String) {
with_world(|w| {
let value: serde_json::Value = serde_json::from_str(&w.stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON: {e}\n{}", w.stdout));
let obj = value.as_object().unwrap_or_else(|| {
panic!("stdout JSON is not an object, cannot check key {key:?}");
});
assert!(
obj.contains_key(&key),
"expected stdout JSON to contain key {key:?}, got keys: {:?}",
obj.keys().collect::<Vec<_>>()
);
});
}
#[then("stdout 的 JSON 键 {key} 为 {value}")]
fn then_stdout_json_key_equals(key: String, value: String) {
with_world(|w| {
let parsed: serde_json::Value = serde_json::from_str(&w.stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON: {e}\n{}", w.stdout));
let actual = parsed
.get(&key)
.unwrap_or_else(|| panic!("stdout JSON missing key {key:?}; got: {parsed}"));
let actual_str = match actual {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
let expected = value.trim().trim_matches('"').to_string();
assert!(
actual_str == expected,
"expected stdout JSON {key:?} = {expected:?}, got {actual_str:?}"
);
});
}
#[then("相对路径 {rel} 存在")]
fn then_rel_path_exists(rel: String) {
let path = fixture_cwd().join(rel.trim().trim_matches('"'));
assert!(path.exists(), "expected path to exist: {}", path.display());
}
#[then("相对路径 {rel} 行数为 {n:usize}")]
fn then_rel_path_line_count(rel: String, n: usize) {
let path = fixture_cwd().join(rel.trim().trim_matches('"'));
let content =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let lines = content.lines().filter(|l| !l.is_empty()).count();
assert_eq!(
lines,
n,
"expected {n} non-empty lines in {}, got {lines}: {content:?}",
path.display()
);
}
#[then("相对路径 {rel} 不存在")]
fn then_rel_path_absent(rel: String) {
let path = fixture_cwd().join(rel.trim().trim_matches('"'));
assert!(
!path.exists(),
"expected path to be absent: {}",
path.display()
);
}
#[then("相对路径 {rel} 内容包含 {text}")]
fn then_rel_path_contains(rel: String, text: String) {
let path = fixture_cwd().join(rel.trim().trim_matches('"'));
let content =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let needle = text.trim().trim_matches('"');
assert!(
content.contains(needle),
"expected {} to contain {:?}, got: {:?}",
path.display(),
needle,
content
);
}
fn record_output(output: std::process::Output) {
let code = output.status.code();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let success = output.status.success();
WORLD.with(|w| {
let mut w = w.borrow_mut();
let world = w.as_mut().expect("world not initialized");
world.exit_code = code;
world.stderr = stderr;
world.stdout = stdout;
world.success = success;
});
}
scenarios!("llmanspec/specs", tags = "@executable");
#[then("最近提交说明包含 {text}")]
fn then_last_commit_subject_contains(text: String) {
let dir = fixture_cwd();
let out = Command::new("git")
.args(["log", "-1", "--format=%s"])
.current_dir(&dir)
.output()
.expect("git log -1");
assert!(out.status.success(), "git log failed");
let subject = String::from_utf8_lossy(&out.stdout);
assert!(
subject.contains(&text),
"expected commit subject to contain {:?}, got {:?}",
text,
subject.trim()
);
}
#[then("最近提交说明不含 {text}")]
fn then_last_commit_subject_not_contains(text: String) {
let dir = fixture_cwd();
let out = Command::new("git")
.args(["log", "-1", "--format=%s"])
.current_dir(&dir)
.output()
.expect("git log -1");
assert!(out.status.success(), "git log failed");
let subject = String::from_utf8_lossy(&out.stdout);
assert!(
!subject.contains(&text),
"expected commit subject to NOT contain {:?}, got {:?}",
text,
subject.trim()
);
}
#[then("工作区存在未提交改动")]
fn then_working_tree_dirty() {
let dir = fixture_cwd();
let out = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&dir)
.output()
.expect("git status");
assert!(out.status.success(), "git status failed");
let status = String::from_utf8_lossy(&out.stdout);
assert!(
!status.trim().is_empty(),
"expected dirty working tree, got clean"
);
}