From 928cc64bfa54571e2ca2b5d9735d6e35d8e5b60f Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 15:31:13 +0000 Subject: [PATCH] Story 46: Deterministic Story Mutations with Auto-Commit - Add git_stage_and_commit() helper for deterministic commits - move_story_to_current() auto-commits on start_agent - accept_story auto-commits move to archived/ - New MCP tools: check_criterion, set_test_plan (total: 21) - create_story MCP always auto-commits - Tests for check_criterion and set_test_plan Co-Authored-By: Claude Opus 4.6 --- server/src/agents.rs | 214 +++++++++++++++++++++- server/src/http/mcp.rs | 121 +++++++++++-- server/src/http/workflow.rs | 345 +++++++++++++++++++++++++++++++++--- 3 files changed, 643 insertions(+), 37 deletions(-) diff --git a/server/src/agents.rs b/server/src/agents.rs index 095cbc5..9311415 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -203,6 +203,9 @@ impl AgentPool { status: "pending".to_string(), }); + // Move story from upcoming/ to current/ and auto-commit before creating the worktree. + move_story_to_current(project_root, story_id)?; + // Create worktree let wt_info = worktree::create_worktree(project_root, story_id, &config, self.port).await?; @@ -578,6 +581,7 @@ impl AgentPool { } /// Return the port this server is running on. + #[allow(dead_code)] pub fn port(&self) -> u16 { self.port } @@ -652,9 +656,84 @@ impl AgentPool { } } -/// Move a story file from current/ to archived/ (human accept action). +/// Stage one or more file paths and create a deterministic commit in the given git root. /// -/// * If the story is in current/, it is renamed to archived/. +/// Pass deleted paths too so git stages their removal alongside any new files. +pub fn git_stage_and_commit( + git_root: &Path, + paths: &[&Path], + message: &str, +) -> Result<(), String> { + let mut add_cmd = Command::new("git"); + add_cmd.arg("add").current_dir(git_root); + for path in paths { + add_cmd.arg(path.to_string_lossy().as_ref()); + } + let output = add_cmd.output().map_err(|e| format!("git add: {e}"))?; + if !output.status.success() { + return Err(format!( + "git add failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + let output = Command::new("git") + .args(["commit", "-m", message]) + .current_dir(git_root) + .output() + .map_err(|e| format!("git commit: {e}"))?; + if !output.status.success() { + return Err(format!( + "git commit failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(()) +} + +/// Move a story file from upcoming/ to current/ and auto-commit to master. +/// +/// Idempotent: if the story is already in current/, returns Ok without committing. +/// If the story is not found in upcoming/, logs a warning and returns Ok (e.g. if +/// the user moved it manually before calling start_agent). +pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> { + let stories_dir = project_root.join(".story_kit").join("stories"); + let upcoming_path = stories_dir.join("upcoming").join(format!("{story_id}.md")); + let current_path = stories_dir.join("current").join(format!("{story_id}.md")); + + if current_path.exists() { + // Already in current/ — idempotent, nothing to do. + return Ok(()); + } + + if !upcoming_path.exists() { + eprintln!( + "[lifecycle] Story '{story_id}' not found in upcoming/; skipping move to current/" + ); + return Ok(()); + } + + let current_dir = stories_dir.join("current"); + std::fs::create_dir_all(¤t_dir) + .map_err(|e| format!("Failed to create current stories directory: {e}"))?; + + std::fs::rename(&upcoming_path, ¤t_path) + .map_err(|e| format!("Failed to move story '{story_id}' to current/: {e}"))?; + + eprintln!("[lifecycle] Moved story '{story_id}' from upcoming/ to current/"); + + let msg = format!("story-kit: start story {story_id}"); + git_stage_and_commit( + project_root, + &[current_path.as_path(), upcoming_path.as_path()], + &msg, + ) +} + +/// Move a story file from current/ to archived/ (human accept action) and auto-commit. +/// +/// * If the story is in current/, it is renamed to archived/ and committed. /// * If the story is already in archived/, this is a no-op (idempotent). /// * If the story is not found in current/ or archived/, an error is returned. pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> { @@ -674,6 +753,13 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), std::fs::rename(¤t_path, &archived_path) .map_err(|e| format!("Failed to move story '{story_id}' to archived/: {e}"))?; eprintln!("[lifecycle] Moved story '{story_id}' from current/ to archived/"); + + let msg = format!("story-kit: accept story {story_id}"); + git_stage_and_commit( + project_root, + &[archived_path.as_path(), current_path.as_path()], + &msg, + )?; return Ok(()); } @@ -1110,4 +1196,128 @@ mod tests { "expected 'uncommitted' in: {msg}" ); } + + // ── move_story_to_current tests ──────────────────────────────────────────── + + fn init_git_repo(repo: &std::path::Path) { + Command::new("git") + .args(["init"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(repo) + .output() + .unwrap(); + } + + #[test] + fn move_story_to_current_moves_file_and_commits() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let upcoming = repo.join(".story_kit/stories/upcoming"); + let current = repo.join(".story_kit/stories/current"); + fs::create_dir_all(&upcoming).unwrap(); + fs::create_dir_all(¤t).unwrap(); + + let story_file = upcoming.join("10_my_story.md"); + fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap(); + + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(repo) + .output() + .unwrap(); + + move_story_to_current(repo, "10_my_story").unwrap(); + + assert!(!story_file.exists(), "upcoming file should be gone"); + assert!( + current.join("10_my_story.md").exists(), + "current file should exist" + ); + } + + #[test] + fn move_story_to_current_is_idempotent_when_already_current() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let current = repo.join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + fs::write( + current.join("11_my_story.md"), + "---\nname: Test\ntest_plan: pending\n---\n", + ) + .unwrap(); + + // Should succeed without error even though there's nothing to move + move_story_to_current(repo, "11_my_story").unwrap(); + + assert!(current.join("11_my_story.md").exists()); + } + + #[test] + fn move_story_to_current_noop_when_not_in_upcoming() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Story doesn't exist anywhere — should return Ok (lenient) + let result = move_story_to_current(repo, "99_missing"); + assert!(result.is_ok(), "should return Ok when story is not found"); + } + + // ── git_stage_and_commit tests ───────────────────────────────────────────── + + #[test] + fn git_stage_and_commit_creates_commit() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let file = repo.join("hello.txt"); + fs::write(&file, "hello").unwrap(); + + git_stage_and_commit(repo, &[file.as_path()], "story-kit: test commit").unwrap(); + + // Verify the commit exists + let output = Command::new("git") + .args(["log", "--oneline", "-1"]) + .current_dir(repo) + .output() + .unwrap(); + let log = String::from_utf8_lossy(&output.stdout); + assert!(log.contains("story-kit: test commit"), "commit should appear in log: {log}"); + } } diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 7961469..154956c 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -2,7 +2,10 @@ use crate::agents::move_story_to_archived; use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::http::settings::get_editor_command_from_store; -use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs}; +use crate::http::workflow::{ + check_criterion_in_file, create_story_file, load_upcoming_stories, set_test_plan_in_file, + validate_story_dirs, +}; use crate::worktree; use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos}; use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus}; @@ -603,7 +606,7 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, { "name": "accept_story", - "description": "Accept a story: moves it from current/ to archived/.", + "description": "Accept a story: moves it from current/ to archived/ and auto-commits to master.", "inputSchema": { "type": "object", "properties": { @@ -614,6 +617,42 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["story_id"] } + }, + { + "name": "check_criterion", + "description": "Check off an acceptance criterion (- [ ] → - [x]) by 0-based index among unchecked items, then auto-commit to master. Use get_story_todos to see the current list of unchecked criteria.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "criterion_index": { + "type": "integer", + "description": "0-based index of the unchecked criterion to check off" + } + }, + "required": ["story_id", "criterion_index"] + } + }, + { + "name": "set_test_plan", + "description": "Update the test_plan front-matter field of a story file and auto-commit to master. Common values: 'pending', 'approved'.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "status": { + "type": "string", + "description": "New value for the test_plan field (e.g. 'approved', 'pending')" + } + }, + "required": ["story_id", "status"] + } } ] }), @@ -659,6 +698,9 @@ async fn handle_tools_call( "report_completion" => tool_report_completion(&args, ctx).await, // Lifecycle tools "accept_story" => tool_accept_story(&args, ctx), + // Story mutation tools (auto-commit to master) + "check_criterion" => tool_check_criterion(&args, ctx), + "set_test_plan" => tool_set_test_plan(&args, ctx), _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -690,10 +732,8 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result { let acceptance_criteria: Option> = args .get("acceptance_criteria") .and_then(|v| serde_json::from_value(v.clone()).ok()); - let commit = args - .get("commit") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + // MCP tool always auto-commits the new story file to master. + let commit = true; let root = ctx.state.get_project_root()?; let story_id = create_story_file( @@ -1067,7 +1107,45 @@ fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result { let project_root = ctx.agents.get_project_root(&ctx.state)?; move_story_to_archived(&project_root, story_id)?; - Ok(format!("Story '{story_id}' accepted and moved to archived/.")) + Ok(format!( + "Story '{story_id}' accepted, moved to archived/, and committed to master." + )) +} + +fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let criterion_index = args + .get("criterion_index") + .and_then(|v| v.as_u64()) + .ok_or("Missing required argument: criterion_index")? as usize; + + let root = ctx.state.get_project_root()?; + check_criterion_in_file(&root, story_id, criterion_index)?; + + Ok(format!( + "Criterion {criterion_index} checked for story '{story_id}'. Committed to master." + )) +} + +fn tool_set_test_plan(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let status = args + .get("status") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: status")?; + + let root = ctx.state.get_project_root()?; + set_test_plan_in_file(&root, story_id, status)?; + + Ok(format!( + "test_plan set to '{status}' for story '{story_id}'. Committed to master." + )) } /// Run `git log ..HEAD --oneline` in the worktree and return the commit @@ -1222,7 +1300,9 @@ mod tests { assert!(names.contains(&"get_editor_command")); assert!(names.contains(&"report_completion")); assert!(names.contains(&"accept_story")); - assert_eq!(tools.len(), 19); + assert!(names.contains(&"check_criterion")); + assert!(names.contains(&"set_test_plan")); + assert_eq!(tools.len(), 21); } #[test] @@ -1253,11 +1333,32 @@ mod tests { #[test] fn tool_create_story_and_list_upcoming() { let tmp = tempfile::tempdir().unwrap(); + // The MCP tool always commits, so we need a real git repo. + std::process::Command::new("git") + .args(["init"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(tmp.path()) + .output() + .unwrap(); let ctx = test_ctx(tmp.path()); - // Create a story + // Create a story (always auto-commits in MCP handler) let result = tool_create_story( - &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"], "commit": false}), + &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}), &ctx, ) .unwrap(); diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 678cd90..6b2157e 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -1,3 +1,4 @@ +use crate::agents::git_stage_and_commit; use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos}; use crate::workflow::{ @@ -8,7 +9,7 @@ use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::collections::BTreeSet; use std::fs; -use std::process::Command; +use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Tags)] @@ -668,40 +669,136 @@ pub fn create_story_file( .map_err(|e| format!("Failed to write story file: {e}"))?; if commit { - git_commit_story_file(root, &filepath, name)?; + git_commit_story_file(root, &filepath, &story_id)?; } Ok(story_id) } -/// Git-add and git-commit a newly created story file to the current branch. -fn git_commit_story_file( - root: &std::path::Path, - filepath: &std::path::Path, - name: &str, +/// Git-add and git-commit a newly created story file using a deterministic message. +fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result<(), String> { + let msg = format!("story-kit: create story {story_id}"); + git_stage_and_commit(root, &[filepath], &msg) +} + +/// Locate a story file by searching current/ then upcoming/. +fn find_story_file(project_root: &Path, story_id: &str) -> Result { + let base = project_root.join(".story_kit").join("stories"); + let filename = format!("{story_id}.md"); + for subdir in &["current", "upcoming"] { + let path = base.join(subdir).join(&filename); + if path.exists() { + return Ok(path); + } + } + Err(format!( + "Story '{story_id}' not found in current/ or upcoming/." + )) +} + +/// Check off the Nth unchecked acceptance criterion in a story file and auto-commit. +/// +/// `criterion_index` is 0-based among unchecked (`- [ ]`) items. +pub fn check_criterion_in_file( + project_root: &Path, + story_id: &str, + criterion_index: usize, ) -> Result<(), String> { - let output = Command::new("git") - .args(["add", &filepath.to_string_lossy()]) - .current_dir(root) - .output() - .map_err(|e| format!("git add: {e}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git add failed: {stderr}")); + let filepath = find_story_file(project_root, story_id)?; + let contents = fs::read_to_string(&filepath) + .map_err(|e| format!("Failed to read story file: {e}"))?; + + let mut unchecked_count: usize = 0; + let mut found = false; + let new_lines: Vec = contents + .lines() + .map(|line| { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("- [ ] ") { + if unchecked_count == criterion_index { + unchecked_count += 1; + found = true; + let indent_len = line.len() - trimmed.len(); + let indent = &line[..indent_len]; + return format!("{indent}- [x] {rest}"); + } + unchecked_count += 1; + } + line.to_string() + }) + .collect(); + + if !found { + return Err(format!( + "Criterion index {criterion_index} out of range. Story '{story_id}' has \ + {unchecked_count} unchecked criteria (indices 0..{}).", + unchecked_count.saturating_sub(1) + )); } - let msg = format!("Add story: {name}"); - let output = Command::new("git") - .args(["commit", "-m", &msg]) - .current_dir(root) - .output() - .map_err(|e| format!("git commit: {e}"))?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("git commit failed: {stderr}")); + let mut new_str = new_lines.join("\n"); + if contents.ends_with('\n') { + new_str.push('\n'); + } + fs::write(&filepath, &new_str) + .map_err(|e| format!("Failed to write story file: {e}"))?; + + let msg = format!("story-kit: check criterion {criterion_index} for story {story_id}"); + git_stage_and_commit(project_root, &[filepath.as_path()], &msg) +} + +/// Update the `test_plan` front-matter field in a story file and auto-commit. +pub fn set_test_plan_in_file( + project_root: &Path, + story_id: &str, + status: &str, +) -> Result<(), String> { + let filepath = find_story_file(project_root, story_id)?; + let contents = fs::read_to_string(&filepath) + .map_err(|e| format!("Failed to read story file: {e}"))?; + + let mut in_front_matter = false; + let mut front_matter_started = false; + let mut found = false; + + let new_lines: Vec = contents + .lines() + .map(|line| { + if line.trim() == "---" { + if !front_matter_started { + front_matter_started = true; + in_front_matter = true; + } else if in_front_matter { + in_front_matter = false; + } + return line.to_string(); + } + if in_front_matter { + let trimmed = line.trim_start(); + if trimmed.starts_with("test_plan:") { + found = true; + return format!("test_plan: {status}"); + } + } + line.to_string() + }) + .collect(); + + if !found { + return Err(format!( + "Story '{story_id}' does not have a 'test_plan' field in its front matter." + )); } - Ok(()) + let mut new_str = new_lines.join("\n"); + if contents.ends_with('\n') { + new_str.push('\n'); + } + fs::write(&filepath, &new_str) + .map_err(|e| format!("Failed to write story file: {e}"))?; + + let msg = format!("story-kit: set test_plan to {status} for story {story_id}"); + git_stage_and_commit(project_root, &[filepath.as_path()], &msg) } fn slugify_name(name: &str) -> String { @@ -1201,4 +1298,202 @@ mod tests { // Simulate the check assert!(filepath.exists()); } + + // ── check_criterion_in_file tests ───────────────────────────────────────── + + fn setup_git_repo(root: &std::path::Path) { + std::process::Command::new("git") + .args(["init"]) + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(root) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(root) + .output() + .unwrap(); + } + + fn story_with_criteria(n: usize) -> String { + let mut s = "---\nname: Test Story\ntest_plan: pending\n---\n\n## Acceptance Criteria\n\n".to_string(); + for i in 0..n { + s.push_str(&format!("- [ ] Criterion {i}\n")); + } + s + } + + #[test] + fn check_criterion_marks_first_unchecked() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("1_test.md"); + fs::write(&filepath, story_with_criteria(3)).unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + check_criterion_in_file(tmp.path(), "1_test", 0).unwrap(); + + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("- [x] Criterion 0"), "first should be checked"); + assert!(contents.contains("- [ ] Criterion 1"), "second should stay unchecked"); + assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked"); + } + + #[test] + fn check_criterion_marks_second_unchecked() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("2_test.md"); + fs::write(&filepath, story_with_criteria(3)).unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + check_criterion_in_file(tmp.path(), "2_test", 1).unwrap(); + + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("- [ ] Criterion 0"), "first should stay unchecked"); + assert!(contents.contains("- [x] Criterion 1"), "second should be checked"); + assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked"); + } + + #[test] + fn check_criterion_out_of_range_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("3_test.md"); + fs::write(&filepath, story_with_criteria(2)).unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let result = check_criterion_in_file(tmp.path(), "3_test", 5); + assert!(result.is_err(), "should fail for out-of-range index"); + assert!(result.unwrap_err().contains("out of range")); + } + + // ── set_test_plan_in_file tests ─────────────────────────────────────────── + + #[test] + fn set_test_plan_updates_pending_to_approved() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("4_test.md"); + fs::write( + &filepath, + "---\nname: Test Story\ntest_plan: pending\n---\n\n## Body\n", + ) + .unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + set_test_plan_in_file(tmp.path(), "4_test", "approved").unwrap(); + + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("test_plan: approved"), "should be updated to approved"); + assert!(!contents.contains("test_plan: pending"), "old value should be replaced"); + } + + #[test] + fn set_test_plan_missing_field_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("5_test.md"); + fs::write( + &filepath, + "---\nname: Test Story\n---\n\n## Body\n", + ) + .unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let result = set_test_plan_in_file(tmp.path(), "5_test", "approved"); + assert!(result.is_err(), "should fail if test_plan field is missing"); + assert!(result.unwrap_err().contains("test_plan")); + } + + #[test] + fn find_story_file_searches_current_then_upcoming() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/stories/current"); + let upcoming = tmp.path().join(".story_kit/stories/upcoming"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&upcoming).unwrap(); + + // Only in upcoming + fs::write(upcoming.join("6_test.md"), "").unwrap(); + let found = find_story_file(tmp.path(), "6_test").unwrap(); + assert!(found.ends_with("upcoming/6_test.md") || found.ends_with("upcoming\\6_test.md")); + + // Also in current — current should win + fs::write(current.join("6_test.md"), "").unwrap(); + let found = find_story_file(tmp.path(), "6_test").unwrap(); + assert!(found.ends_with("current/6_test.md") || found.ends_with("current\\6_test.md")); + } + + #[test] + fn find_story_file_returns_error_when_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let result = find_story_file(tmp.path(), "99_missing"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found")); + } }