use crate::agents::AgentStatus; use crate::http::context::AppContext; use crate::io::story_metadata::parse_front_matter; use serde::Serialize; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; /// Agent assignment embedded in a pipeline stage item. #[derive(Clone, Debug, Serialize)] pub struct AgentAssignment { pub agent_name: String, pub model: Option, pub status: String, } #[derive(Clone, Debug, Serialize)] pub struct UpcomingStory { pub story_id: String, pub name: Option, pub error: Option, /// Active agent working on this item, if any. pub agent: Option, } pub struct StoryValidationResult { pub story_id: String, pub valid: bool, pub error: Option, } /// Full pipeline state across all stages. #[derive(Clone, Debug, Serialize)] pub struct PipelineState { pub upcoming: Vec, pub current: Vec, pub qa: Vec, pub merge: Vec, } /// Load the full pipeline state (all 4 active stages). pub fn load_pipeline_state(ctx: &AppContext) -> Result { let agent_map = build_active_agent_map(ctx); Ok(PipelineState { upcoming: load_stage_items(ctx, "1_upcoming", &HashMap::new())?, current: load_stage_items(ctx, "2_current", &agent_map)?, qa: load_stage_items(ctx, "3_qa", &agent_map)?, merge: load_stage_items(ctx, "4_merge", &agent_map)?, }) } /// Build a map from story_id → AgentAssignment for all pending/running agents. fn build_active_agent_map(ctx: &AppContext) -> HashMap { let agents = match ctx.agents.list_agents() { Ok(a) => a, Err(_) => return HashMap::new(), }; let config_opt = ctx .state .get_project_root() .ok() .and_then(|root| crate::config::ProjectConfig::load(&root).ok()); let mut map = HashMap::new(); for agent in agents { if !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) { continue; } let model = config_opt .as_ref() .and_then(|cfg| cfg.find_agent(&agent.agent_name)) .and_then(|ac| ac.model.clone()); map.insert( agent.story_id.clone(), AgentAssignment { agent_name: agent.agent_name, model, status: agent.status.to_string(), }, ); } map } /// Load work items from any pipeline stage directory. fn load_stage_items( ctx: &AppContext, stage_dir: &str, agent_map: &HashMap, ) -> Result, String> { let root = ctx.state.get_project_root()?; let dir = root.join(".story_kit").join("work").join(stage_dir); if !dir.exists() { return Ok(Vec::new()); } let mut stories = Vec::new(); for entry in fs::read_dir(&dir) .map_err(|e| format!("Failed to read {stage_dir} directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read {stage_dir} entry: {e}"))?; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("md") { continue; } let story_id = path .file_stem() .and_then(|stem| stem.to_str()) .ok_or_else(|| "Invalid story file name.".to_string())? .to_string(); let contents = fs::read_to_string(&path) .map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?; let (name, error) = match parse_front_matter(&contents) { Ok(meta) => (meta.name, None), Err(e) => (None, Some(e.to_string())), }; let agent = agent_map.get(&story_id).cloned(); stories.push(UpcomingStory { story_id, name, error, agent }); } stories.sort_by(|a, b| a.story_id.cmp(&b.story_id)); Ok(stories) } pub fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { load_stage_items(ctx, "1_upcoming", &HashMap::new()) } /// Shared create-story logic used by both the OpenApi and MCP handlers. /// /// When `commit` is `true`, the new story file is git-added and committed to /// the current branch immediately after creation. pub fn create_story_file( root: &std::path::Path, name: &str, user_story: Option<&str>, acceptance_criteria: Option<&[String]>, commit: bool, ) -> Result { let story_number = next_item_number(root)?; let slug = slugify_name(name); if slug.is_empty() { return Err("Name must contain at least one alphanumeric character.".to_string()); } let filename = format!("{story_number}_story_{slug}.md"); let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming"); fs::create_dir_all(&upcoming_dir) .map_err(|e| format!("Failed to create upcoming directory: {e}"))?; let filepath = upcoming_dir.join(&filename); if filepath.exists() { return Err(format!("Story file already exists: {filename}")); } let story_id = filepath .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default() .to_string(); let mut content = String::new(); content.push_str("---\n"); content.push_str(&format!("name: {name}\n")); content.push_str("test_plan: pending\n"); content.push_str("---\n\n"); content.push_str(&format!("# Story {story_number}: {name}\n\n")); content.push_str("## User Story\n\n"); if let Some(us) = user_story { content.push_str(us); content.push('\n'); } else { content.push_str("As a ..., I want ..., so that ...\n"); } content.push('\n'); content.push_str("## Acceptance Criteria\n\n"); if let Some(criteria) = acceptance_criteria { for criterion in criteria { content.push_str(&format!("- [ ] {criterion}\n")); } } else { content.push_str("- [ ] TODO\n"); } content.push('\n'); content.push_str("## Out of Scope\n\n"); content.push_str("- TBD\n"); fs::write(&filepath, &content) .map_err(|e| format!("Failed to write story file: {e}"))?; // Watcher handles the git commit asynchronously. let _ = commit; // kept for API compat, ignored Ok(story_id) } // ── Bug file helpers ────────────────────────────────────────────── /// Create a bug file in `work/1_upcoming/` with a deterministic filename and auto-commit. /// /// Returns the bug_id (e.g. `"4_bug_login_crash"`). pub fn create_bug_file( root: &Path, name: &str, description: &str, steps_to_reproduce: &str, actual_result: &str, expected_result: &str, acceptance_criteria: Option<&[String]>, ) -> Result { let bug_number = next_item_number(root)?; let slug = slugify_name(name); if slug.is_empty() { return Err("Name must contain at least one alphanumeric character.".to_string()); } let filename = format!("{bug_number}_bug_{slug}.md"); let bugs_dir = root.join(".story_kit").join("work").join("1_upcoming"); fs::create_dir_all(&bugs_dir) .map_err(|e| format!("Failed to create upcoming directory: {e}"))?; let filepath = bugs_dir.join(&filename); if filepath.exists() { return Err(format!("Bug file already exists: {filename}")); } let bug_id = filepath .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default() .to_string(); let mut content = String::new(); content.push_str(&format!("# Bug {bug_number}: {name}\n\n")); content.push_str("## Description\n\n"); content.push_str(description); content.push_str("\n\n"); content.push_str("## How to Reproduce\n\n"); content.push_str(steps_to_reproduce); content.push_str("\n\n"); content.push_str("## Actual Result\n\n"); content.push_str(actual_result); content.push_str("\n\n"); content.push_str("## Expected Result\n\n"); content.push_str(expected_result); content.push_str("\n\n"); content.push_str("## Acceptance Criteria\n\n"); if let Some(criteria) = acceptance_criteria { for criterion in criteria { content.push_str(&format!("- [ ] {criterion}\n")); } } else { content.push_str("- [ ] Bug is fixed and verified\n"); } fs::write(&filepath, &content).map_err(|e| format!("Failed to write bug file: {e}"))?; // Watcher handles the git commit asynchronously. Ok(bug_id) } /// Returns true if the item stem (filename without extension) is a bug item. /// Bug items follow the pattern: {N}_bug_{slug} fn is_bug_item(stem: &str) -> bool { // Format: {digits}_bug_{rest} let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); after_num.starts_with("_bug_") } /// Extract the human-readable name from a bug file's first heading. fn extract_bug_name(path: &Path) -> Option { let contents = fs::read_to_string(path).ok()?; for line in contents.lines() { if let Some(rest) = line.strip_prefix("# Bug ") { // Format: "N: Name" if let Some(colon_pos) = rest.find(": ") { return Some(rest[colon_pos + 2..].to_string()); } } } None } /// List all open bugs — files in `work/1_upcoming/` matching the `_bug_` naming pattern. /// /// Returns a sorted list of `(bug_id, name)` pairs. pub fn list_bug_files(root: &Path) -> Result, String> { let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming"); if !upcoming_dir.exists() { return Ok(Vec::new()); } let mut bugs = Vec::new(); for entry in fs::read_dir(&upcoming_dir).map_err(|e| format!("Failed to read upcoming directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; let path = entry.path(); if path.is_dir() { continue; } if path.extension().and_then(|ext| ext.to_str()) != Some("md") { continue; } let stem = path .file_stem() .and_then(|s| s.to_str()) .ok_or_else(|| "Invalid file name.".to_string())?; // Only include bug items: {N}_bug_{slug} if !is_bug_item(stem) { continue; } let bug_id = stem.to_string(); let name = extract_bug_name(&path).unwrap_or_else(|| bug_id.clone()); bugs.push((bug_id, name)); } bugs.sort_by(|a, b| a.0.cmp(&b.0)); Ok(bugs) } /// Locate a work item file by searching work/2_current/ then work/1_upcoming/. fn find_story_file(project_root: &Path, story_id: &str) -> Result { let filename = format!("{story_id}.md"); let sk = project_root.join(".story_kit").join("work"); // Check 2_current/ first let current_path = sk.join("2_current").join(&filename); if current_path.exists() { return Ok(current_path); } // Fall back to 1_upcoming/ let upcoming_path = sk.join("1_upcoming").join(&filename); if upcoming_path.exists() { return Ok(upcoming_path); } Err(format!( "Story '{story_id}' not found in work/2_current/ or work/1_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 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 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}"))?; // Watcher handles the git commit asynchronously. Ok(()) } /// 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." )); } 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}"))?; // Watcher handles the git commit asynchronously. Ok(()) } fn slugify_name(name: &str) -> String { let slug: String = name .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '_' } }) .collect(); // Collapse consecutive underscores and trim edges let mut result = String::new(); let mut prev_underscore = true; // start true to trim leading _ for ch in slug.chars() { if ch == '_' { if !prev_underscore { result.push('_'); } prev_underscore = true; } else { result.push(ch); prev_underscore = false; } } // Trim trailing underscore if result.ends_with('_') { result.pop(); } result } /// Scan all `work/` subdirectories for the highest item number across all types (stories, bugs, spikes). fn next_item_number(root: &std::path::Path) -> Result { let work_base = root.join(".story_kit").join("work"); let mut max_num: u32 = 0; for subdir in &["1_upcoming", "2_current", "3_qa", "4_merge", "5_archived"] { let dir = work_base.join(subdir); if !dir.exists() { continue; } for entry in fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; let name = entry.file_name(); let name_str = name.to_string_lossy(); // Filename format: {N}_{type}_{slug}.md — extract leading N let num_str: String = name_str.chars().take_while(|c| c.is_ascii_digit()).collect(); if let Ok(n) = num_str.parse::() && n > max_num { max_num = n; } } } Ok(max_num + 1) } pub fn validate_story_dirs( root: &std::path::Path, ) -> Result, String> { let mut results = Vec::new(); // Directories to validate: work/2_current/ + work/1_upcoming/ let dirs_to_validate: Vec = vec![ root.join(".story_kit").join("work").join("2_current"), root.join(".story_kit").join("work").join("1_upcoming"), ]; for dir in &dirs_to_validate { let subdir = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); if !dir.exists() { continue; } for entry in fs::read_dir(dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("md") { continue; } let story_id = path .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or_default() .to_string(); let contents = fs::read_to_string(&path) .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; match parse_front_matter(&contents) { Ok(meta) => { let mut errors = Vec::new(); if meta.name.is_none() { errors.push("Missing 'name' field".to_string()); } if meta.test_plan.is_none() { errors.push("Missing 'test_plan' field".to_string()); } if errors.is_empty() { results.push(StoryValidationResult { story_id, valid: true, error: None, }); } else { results.push(StoryValidationResult { story_id, valid: false, error: Some(errors.join("; ")), }); } } Err(e) => results.push(StoryValidationResult { story_id, valid: false, error: Some(e.to_string()), }), } } } results.sort_by(|a, b| a.story_id.cmp(&b.story_id)); Ok(results) } #[cfg(test)] mod tests { use super::*; #[test] fn load_upcoming_returns_empty_when_no_dir() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); // No .story_kit directory at all let ctx = crate::http::context::AppContext::new_test(root); let result = load_upcoming_stories(&ctx).unwrap(); assert!(result.is_empty()); } #[test] fn pipeline_state_includes_agent_for_running_story() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); let current = root.join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write( current.join("10_story_test.md"), "---\nname: Test Story\ntest_plan: approved\n---\n# Story\n", ) .unwrap(); let ctx = crate::http::context::AppContext::new_test(root); ctx.agents.inject_test_agent("10_story_test", "coder-1", crate::agents::AgentStatus::Running); let state = load_pipeline_state(&ctx).unwrap(); assert_eq!(state.current.len(), 1); let item = &state.current[0]; assert!(item.agent.is_some(), "running agent should appear on work item"); let agent = item.agent.as_ref().unwrap(); assert_eq!(agent.agent_name, "coder-1"); assert_eq!(agent.status, "running"); } #[test] fn pipeline_state_no_agent_for_completed_story() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); let current = root.join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write( current.join("11_story_done.md"), "---\nname: Done Story\ntest_plan: approved\n---\n# Story\n", ) .unwrap(); let ctx = crate::http::context::AppContext::new_test(root); ctx.agents.inject_test_agent("11_story_done", "coder-1", crate::agents::AgentStatus::Completed); let state = load_pipeline_state(&ctx).unwrap(); assert_eq!(state.current.len(), 1); assert!( state.current[0].agent.is_none(), "completed agent should not appear on work item" ); } #[test] fn pipeline_state_pending_agent_included() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); let current = root.join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write( current.join("12_story_pending.md"), "---\nname: Pending Story\ntest_plan: approved\n---\n# Story\n", ) .unwrap(); let ctx = crate::http::context::AppContext::new_test(root); ctx.agents.inject_test_agent("12_story_pending", "coder-1", crate::agents::AgentStatus::Pending); let state = load_pipeline_state(&ctx).unwrap(); assert_eq!(state.current.len(), 1); let item = &state.current[0]; assert!(item.agent.is_some(), "pending agent should appear on work item"); assert_eq!(item.agent.as_ref().unwrap().status, "pending"); } #[test] fn load_upcoming_parses_metadata() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write( upcoming.join("31_story_view_upcoming.md"), "---\nname: View Upcoming\ntest_plan: pending\n---\n# Story\n", ) .unwrap(); fs::write( upcoming.join("32_story_worktree.md"), "---\nname: Worktree Orchestration\ntest_plan: pending\n---\n# Story\n", ) .unwrap(); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let stories = load_upcoming_stories(&ctx).unwrap(); assert_eq!(stories.len(), 2); assert_eq!(stories[0].story_id, "31_story_view_upcoming"); assert_eq!(stories[0].name.as_deref(), Some("View Upcoming")); assert_eq!(stories[1].story_id, "32_story_worktree"); assert_eq!(stories[1].name.as_deref(), Some("Worktree Orchestration")); } #[test] fn load_upcoming_skips_non_md_files() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join(".gitkeep"), "").unwrap(); fs::write( upcoming.join("31_story_example.md"), "---\nname: A Story\ntest_plan: pending\n---\n", ) .unwrap(); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let stories = load_upcoming_stories(&ctx).unwrap(); assert_eq!(stories.len(), 1); assert_eq!(stories[0].story_id, "31_story_example"); } #[test] fn validate_story_dirs_valid_files() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&upcoming).unwrap(); fs::write( current.join("28_story_todos.md"), "---\nname: Show TODOs\ntest_plan: approved\n---\n# Story\n", ) .unwrap(); fs::write( upcoming.join("36_story_front_matter.md"), "---\nname: Enforce Front Matter\ntest_plan: pending\n---\n# Story\n", ) .unwrap(); let results = validate_story_dirs(tmp.path()).unwrap(); assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.valid)); assert!(results.iter().all(|r| r.error.is_none())); } #[test] fn validate_story_dirs_missing_front_matter() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("28_story_todos.md"), "# No front matter\n").unwrap(); let results = validate_story_dirs(tmp.path()).unwrap(); assert_eq!(results.len(), 1); assert!(!results[0].valid); assert_eq!(results[0].error.as_deref(), Some("Missing front matter")); } #[test] fn validate_story_dirs_missing_required_fields() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("28_story_todos.md"), "---\n---\n# Story\n").unwrap(); let results = validate_story_dirs(tmp.path()).unwrap(); assert_eq!(results.len(), 1); assert!(!results[0].valid); let err = results[0].error.as_deref().unwrap(); assert!(err.contains("Missing 'name' field")); assert!(err.contains("Missing 'test_plan' field")); } #[test] fn validate_story_dirs_missing_test_plan_only() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/work/2_current"); fs::create_dir_all(¤t).unwrap(); fs::write( current.join("28_story_todos.md"), "---\nname: A Story\n---\n# Story\n", ) .unwrap(); let results = validate_story_dirs(tmp.path()).unwrap(); assert_eq!(results.len(), 1); assert!(!results[0].valid); let err = results[0].error.as_deref().unwrap(); assert!(err.contains("Missing 'test_plan' field")); assert!(!err.contains("Missing 'name' field")); } #[test] fn validate_story_dirs_empty_when_no_dirs() { let tmp = tempfile::tempdir().unwrap(); let results = validate_story_dirs(tmp.path()).unwrap(); assert!(results.is_empty()); } // --- slugify_name tests --- #[test] fn slugify_simple_name() { assert_eq!( slugify_name("Enforce Front Matter on All Story Files"), "enforce_front_matter_on_all_story_files" ); } #[test] fn slugify_with_special_chars() { assert_eq!(slugify_name("Hello, World! (v2)"), "hello_world_v2"); } #[test] fn slugify_leading_trailing_underscores() { assert_eq!(slugify_name(" spaces "), "spaces"); } #[test] fn slugify_consecutive_separators() { assert_eq!(slugify_name("a--b__c d"), "a_b_c_d"); } #[test] fn slugify_empty_after_strip() { assert_eq!(slugify_name("!!!"), ""); } #[test] fn slugify_already_snake_case() { assert_eq!(slugify_name("my_story_name"), "my_story_name"); } // --- next_item_number tests --- #[test] fn next_item_number_empty_dirs() { let tmp = tempfile::tempdir().unwrap(); let base = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(&base).unwrap(); assert_eq!(next_item_number(tmp.path()).unwrap(), 1); } #[test] fn next_item_number_scans_all_dirs() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); let current = tmp.path().join(".story_kit/work/2_current"); let archived = tmp.path().join(".story_kit/work/5_archived"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&archived).unwrap(); fs::write(upcoming.join("10_story_foo.md"), "").unwrap(); fs::write(current.join("20_story_bar.md"), "").unwrap(); fs::write(archived.join("15_story_baz.md"), "").unwrap(); assert_eq!(next_item_number(tmp.path()).unwrap(), 21); } #[test] fn next_item_number_no_work_dirs() { let tmp = tempfile::tempdir().unwrap(); // No .story_kit at all assert_eq!(next_item_number(tmp.path()).unwrap(), 1); } // --- create_story integration tests --- #[test] fn create_story_writes_correct_content() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join("36_story_existing.md"), "").unwrap(); let number = next_item_number(tmp.path()).unwrap(); assert_eq!(number, 37); let slug = slugify_name("My New Feature"); assert_eq!(slug, "my_new_feature"); let filename = format!("{number}_{slug}.md"); let filepath = upcoming.join(&filename); let mut content = String::new(); content.push_str("---\n"); content.push_str("name: My New Feature\n"); content.push_str("test_plan: pending\n"); content.push_str("---\n\n"); content.push_str(&format!("# Story {number}: My New Feature\n\n")); content.push_str("## User Story\n\n"); content.push_str("As a dev, I want this feature\n\n"); content.push_str("## Acceptance Criteria\n\n"); content.push_str("- [ ] It works\n"); content.push_str("- [ ] It is tested\n\n"); content.push_str("## Out of Scope\n\n"); content.push_str("- TBD\n"); fs::write(&filepath, &content).unwrap(); let written = fs::read_to_string(&filepath).unwrap(); assert!(written.starts_with("---\nname: My New Feature\ntest_plan: pending\n---")); assert!(written.contains("# Story 37: My New Feature")); assert!(written.contains("- [ ] It works")); assert!(written.contains("- [ ] It is tested")); assert!(written.contains("## Out of Scope")); } #[test] fn create_story_rejects_duplicate() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(&upcoming).unwrap(); let filepath = upcoming.join("1_story_my_feature.md"); fs::write(&filepath, "existing").unwrap(); // 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/work/2_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/work/2_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/work/2_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/work/2_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/work/2_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/work/2_current"); let upcoming = tmp.path().join(".story_kit/work/1_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("1_upcoming/6_test.md") || found.ends_with("1_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("2_current/6_test.md") || found.ends_with("2_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")); } // ── Bug file helper tests ────────────────────────────────────────────────── #[test] fn next_item_number_starts_at_1_when_empty_bugs() { let tmp = tempfile::tempdir().unwrap(); assert_eq!(next_item_number(tmp.path()).unwrap(), 1); } #[test] fn next_item_number_increments_from_existing_bugs() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join("1_bug_crash.md"), "").unwrap(); fs::write(upcoming.join("3_bug_another.md"), "").unwrap(); assert_eq!(next_item_number(tmp.path()).unwrap(), 4); } #[test] fn next_item_number_scans_archived_too() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/work/1_upcoming"); let archived = tmp.path().join(".story_kit/work/5_archived"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(&archived).unwrap(); fs::write(archived.join("5_bug_old.md"), "").unwrap(); assert_eq!(next_item_number(tmp.path()).unwrap(), 6); } #[test] fn list_bug_files_empty_when_no_bugs_dir() { let tmp = tempfile::tempdir().unwrap(); let result = list_bug_files(tmp.path()).unwrap(); assert!(result.is_empty()); } #[test] fn list_bug_files_excludes_archive_subdir() { let tmp = tempfile::tempdir().unwrap(); let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming"); let archived_dir = tmp.path().join(".story_kit/work/5_archived"); fs::create_dir_all(&upcoming_dir).unwrap(); fs::create_dir_all(&archived_dir).unwrap(); fs::write(upcoming_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap(); fs::write(archived_dir.join("2_bug_closed.md"), "# Bug 2: Closed Bug\n").unwrap(); let result = list_bug_files(tmp.path()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].0, "1_bug_open"); assert_eq!(result[0].1, "Open Bug"); } #[test] fn list_bug_files_sorted_by_id() { let tmp = tempfile::tempdir().unwrap(); let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming"); fs::create_dir_all(&upcoming_dir).unwrap(); fs::write(upcoming_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap(); fs::write(upcoming_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap(); fs::write(upcoming_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap(); let result = list_bug_files(tmp.path()).unwrap(); assert_eq!(result.len(), 3); assert_eq!(result[0].0, "1_bug_first"); assert_eq!(result[1].0, "2_bug_second"); assert_eq!(result[2].0, "3_bug_third"); } #[test] fn extract_bug_name_parses_heading() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("bug-1-crash.md"); fs::write(&path, "# Bug 1: Login page crashes\n\n## Description\n").unwrap(); let name = extract_bug_name(&path).unwrap(); assert_eq!(name, "Login page crashes"); } #[test] fn create_bug_file_writes_correct_content() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo(tmp.path()); let bug_id = create_bug_file( tmp.path(), "Login Crash", "The login page crashes on submit.", "1. Go to /login\n2. Click submit", "Page crashes with 500 error", "Login succeeds", Some(&["Login form submits without error".to_string()]), ) .unwrap(); assert_eq!(bug_id, "1_bug_login_crash"); let filepath = tmp .path() .join(".story_kit/work/1_upcoming/1_bug_login_crash.md"); assert!(filepath.exists()); let contents = fs::read_to_string(&filepath).unwrap(); assert!(contents.contains("# Bug 1: Login Crash")); assert!(contents.contains("## Description")); assert!(contents.contains("The login page crashes on submit.")); assert!(contents.contains("## How to Reproduce")); assert!(contents.contains("1. Go to /login")); assert!(contents.contains("## Actual Result")); assert!(contents.contains("Page crashes with 500 error")); assert!(contents.contains("## Expected Result")); assert!(contents.contains("Login succeeds")); assert!(contents.contains("## Acceptance Criteria")); assert!(contents.contains("- [ ] Login form submits without error")); } #[test] fn create_bug_file_rejects_empty_name() { let tmp = tempfile::tempdir().unwrap(); let result = create_bug_file(tmp.path(), "!!!", "desc", "steps", "actual", "expected", None); assert!(result.is_err()); assert!(result.unwrap_err().contains("alphanumeric")); } #[test] fn create_bug_file_uses_default_acceptance_criterion() { let tmp = tempfile::tempdir().unwrap(); setup_git_repo(tmp.path()); create_bug_file( tmp.path(), "Some Bug", "desc", "steps", "actual", "expected", None, ) .unwrap(); let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_bug_some_bug.md"); let contents = fs::read_to_string(&filepath).unwrap(); assert!(contents.contains("- [ ] Bug is fixed and verified")); } }