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::{ CoverageReport, StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance_with_coverage, parse_coverage_json, summarize_results, }; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; #[derive(Tags)] enum WorkflowTags { Workflow, } #[derive(Deserialize, Object)] struct TestCasePayload { pub name: String, pub status: String, pub details: Option, } #[derive(Deserialize, Object)] struct RecordTestsPayload { pub story_id: String, pub unit: Vec, pub integration: Vec, } #[derive(Deserialize, Object)] struct AcceptanceRequest { pub story_id: String, } #[derive(Object)] struct TestRunSummaryResponse { pub total: usize, pub passed: usize, pub failed: usize, } #[derive(Object)] struct CoverageReportResponse { pub current_percent: f64, pub threshold_percent: f64, pub baseline_percent: Option, } #[derive(Object)] struct AcceptanceResponse { pub can_accept: bool, pub reasons: Vec, pub warning: Option, pub summary: TestRunSummaryResponse, pub missing_categories: Vec, pub coverage_report: Option, } #[derive(Object)] struct ReviewStory { pub story_id: String, pub can_accept: bool, pub reasons: Vec, pub warning: Option, pub summary: TestRunSummaryResponse, pub missing_categories: Vec, pub coverage_report: Option, } #[derive(Deserialize, Object)] struct RecordCoveragePayload { pub story_id: String, pub current_percent: f64, pub threshold_percent: Option, } #[derive(Deserialize, Object)] struct CollectCoverageRequest { pub story_id: String, pub threshold_percent: Option, } #[derive(Object)] struct ReviewListResponse { pub stories: Vec, } #[derive(Object)] struct StoryTodosResponse { pub story_id: String, pub story_name: Option, pub todos: Vec, pub error: Option, } #[derive(Object)] struct TodoListResponse { pub stories: Vec, } #[derive(Object)] pub struct UpcomingStory { pub story_id: String, pub name: Option, pub error: Option, } #[derive(Object)] struct UpcomingStoriesResponse { pub stories: Vec, } #[derive(Deserialize, Object)] struct CreateStoryPayload { pub name: String, pub user_story: Option, pub acceptance_criteria: Option>, /// If true, git-add and git-commit the new story file to the current branch. pub commit: Option, } #[derive(Object)] struct CreateStoryResponse { pub story_id: String, } #[derive(Object)] pub struct StoryValidationResult { pub story_id: String, pub valid: bool, pub error: Option, } #[derive(Object)] struct ValidateStoriesResponse { pub stories: Vec, } pub fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { let root = ctx.state.get_project_root()?; let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming"); if !upcoming_dir.exists() { return Ok(Vec::new()); } let mut stories = Vec::new(); for entry in fs::read_dir(&upcoming_dir) .map_err(|e| format!("Failed to read upcoming stories directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read upcoming story 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())), }; stories.push(UpcomingStory { story_id, name, error }); } stories.sort_by(|a, b| a.story_id.cmp(&b.story_id)); Ok(stories) } fn load_current_story_metadata(ctx: &AppContext) -> Result, String> { let root = ctx.state.get_project_root()?; let current_dir = root.join(".story_kit").join("work").join("2_current"); if !current_dir.exists() { return Ok(Vec::new()); } let mut stories = Vec::new(); for entry in fs::read_dir(¤t_dir) .map_err(|e| format!("Failed to read current stories directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read current story 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 metadata = parse_front_matter(&contents) .map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?; stories.push((story_id, metadata)); } Ok(stories) } fn to_review_story( story_id: &str, results: &StoryTestResults, coverage: Option<&CoverageReport>, ) -> ReviewStory { let decision = evaluate_acceptance_with_coverage(results, coverage); let summary = summarize_results(results); let mut missing_categories = Vec::new(); let mut reasons = decision.reasons; if results.unit.is_empty() { missing_categories.push("unit".to_string()); reasons.push("Missing unit test results.".to_string()); } if results.integration.is_empty() { missing_categories.push("integration".to_string()); reasons.push("Missing integration test results.".to_string()); } let can_accept = decision.can_accept && missing_categories.is_empty(); let coverage_report = coverage.map(|c| CoverageReportResponse { current_percent: c.current_percent, threshold_percent: c.threshold_percent, baseline_percent: c.baseline_percent, }); ReviewStory { story_id: story_id.to_string(), can_accept, reasons, warning: decision.warning, summary: TestRunSummaryResponse { total: summary.total, passed: summary.passed, failed: summary.failed, }, missing_categories, coverage_report, } } pub struct WorkflowApi { pub ctx: Arc, } #[OpenApi(tag = "WorkflowTags::Workflow")] impl WorkflowApi { /// Record test results for a story (unit + integration). #[oai(path = "/workflow/tests/record", method = "post")] async fn record_tests(&self, payload: Json) -> OpenApiResult> { let unit = payload .0 .unit .into_iter() .map(to_test_case) .collect::, String>>() .map_err(bad_request)?; let integration = payload .0 .integration .into_iter() .map(to_test_case) .collect::, String>>() .map_err(bad_request)?; let mut workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; workflow .record_test_results_validated(payload.0.story_id, unit, integration) .map_err(bad_request)?; Ok(Json(true)) } /// Evaluate acceptance readiness for a story. #[oai(path = "/workflow/acceptance", method = "post")] async fn acceptance( &self, payload: Json, ) -> OpenApiResult> { let (results, coverage) = { let workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; let results = workflow .results .get(&payload.0.story_id) .cloned() .unwrap_or_default(); let coverage = workflow.coverage.get(&payload.0.story_id).cloned(); (results, coverage) }; let decision = evaluate_acceptance_with_coverage(&results, coverage.as_ref()); let summary = summarize_results(&results); let mut missing_categories = Vec::new(); let mut reasons = decision.reasons; if results.unit.is_empty() { missing_categories.push("unit".to_string()); reasons.push("Missing unit test results.".to_string()); } if results.integration.is_empty() { missing_categories.push("integration".to_string()); reasons.push("Missing integration test results.".to_string()); } let can_accept = decision.can_accept && missing_categories.is_empty(); let coverage_report = coverage.map(|c| CoverageReportResponse { current_percent: c.current_percent, threshold_percent: c.threshold_percent, baseline_percent: c.baseline_percent, }); Ok(Json(AcceptanceResponse { can_accept, reasons, warning: decision.warning, summary: TestRunSummaryResponse { total: summary.total, passed: summary.passed, failed: summary.failed, }, missing_categories, coverage_report, })) } /// List stories that are ready for human review. #[oai(path = "/workflow/review", method = "get")] async fn review_queue(&self) -> OpenApiResult> { let stories = { let workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; workflow .results .iter() .map(|(story_id, results)| { let coverage = workflow.coverage.get(story_id); to_review_story(story_id, results, coverage) }) .filter(|story| story.can_accept) .collect::>() }; Ok(Json(ReviewListResponse { stories })) } /// List stories in the review queue, including blocked items and current stories. #[oai(path = "/workflow/review/all", method = "get")] async fn review_queue_all(&self) -> OpenApiResult> { let current_stories = load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?; let stories = { let mut workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; if !current_stories.is_empty() { workflow.load_story_metadata(current_stories); } let mut story_ids = BTreeSet::new(); for story_id in workflow.results.keys() { story_ids.insert(story_id.clone()); } for story_id in workflow.stories.keys() { story_ids.insert(story_id.clone()); } story_ids .into_iter() .map(|story_id| { let results = workflow.results.get(&story_id).cloned().unwrap_or_default(); let coverage = workflow.coverage.get(&story_id); to_review_story(&story_id, &results, coverage) }) .collect::>() }; Ok(Json(ReviewListResponse { stories })) } /// Record coverage data for a story. #[oai(path = "/workflow/coverage/record", method = "post")] async fn record_coverage( &self, payload: Json, ) -> OpenApiResult> { let mut workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; workflow.record_coverage( payload.0.story_id, payload.0.current_percent, payload.0.threshold_percent, ); Ok(Json(true)) } /// Run coverage collection: execute test:coverage, parse output, record result. #[oai(path = "/workflow/coverage/collect", method = "post")] async fn collect_coverage( &self, payload: Json, ) -> OpenApiResult> { let root = self .ctx .state .get_project_root() .map_err(bad_request)?; let frontend_dir = root.join("frontend"); // Run pnpm run test:coverage in the frontend directory let output = tokio::task::spawn_blocking(move || { std::process::Command::new("pnpm") .args(["run", "test:coverage"]) .current_dir(&frontend_dir) .output() }) .await .map_err(|e| bad_request(format!("Task join error: {e}")))? .map_err(|e| bad_request(format!("Failed to run coverage command: {e}")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); let combined: Vec<&str> = stdout .lines() .chain(stderr.lines()) .filter(|l| !l.trim().is_empty()) .collect(); let tail: Vec<&str> = combined .iter() .rev() .take(5) .rev() .copied() .collect(); let summary = if tail.is_empty() { "Unknown error. Check server logs for details.".to_string() } else { tail.join("\n") }; return Err(bad_request(format!("Coverage command failed:\n{summary}"))); } // Read the coverage summary JSON let summary_path = root .join("frontend") .join("coverage") .join("coverage-summary.json"); let json_str = fs::read_to_string(&summary_path) .map_err(|e| bad_request(format!("Failed to read coverage summary: {e}")))?; let current_percent = parse_coverage_json(&json_str).map_err(bad_request)?; // Record coverage in workflow state let coverage_report = { let mut workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; workflow.record_coverage( payload.0.story_id.clone(), current_percent, payload.0.threshold_percent, ); workflow .coverage .get(&payload.0.story_id) .cloned() .expect("just inserted") }; Ok(Json(CoverageReportResponse { current_percent: coverage_report.current_percent, threshold_percent: coverage_report.threshold_percent, baseline_percent: coverage_report.baseline_percent, })) } /// List unchecked acceptance criteria (TODOs) for all current stories. #[oai(path = "/workflow/todos", method = "get")] async fn story_todos(&self) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let current_dir = root.join(".story_kit").join("work").join("2_current"); if !current_dir.exists() { return Ok(Json(TodoListResponse { stories: Vec::new(), })); } let mut stories = Vec::new(); let mut entries: Vec<_> = fs::read_dir(¤t_dir) .map_err(|e| bad_request(format!("Failed to read current stories: {e}")))? .filter_map(|e| e.ok()) .collect(); entries.sort_by_key(|e| e.file_name()); for entry in entries { 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| bad_request(format!("Failed to read {}: {e}", path.display())))?; let (story_name, error) = match parse_front_matter(&contents) { Ok(m) => (m.name, None), Err(e) => (None, Some(e.to_string())), }; let todos = parse_unchecked_todos(&contents); stories.push(StoryTodosResponse { story_id, story_name, todos, error, }); } Ok(Json(TodoListResponse { stories })) } /// List upcoming stories from .story_kit/stories/upcoming/. #[oai(path = "/workflow/upcoming", method = "get")] async fn list_upcoming_stories(&self) -> OpenApiResult> { let stories = load_upcoming_stories(self.ctx.as_ref()).map_err(bad_request)?; Ok(Json(UpcomingStoriesResponse { stories })) } /// Validate front matter on all current and upcoming story files. #[oai(path = "/workflow/stories/validate", method = "get")] async fn validate_stories(&self) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let stories = validate_story_dirs(&root).map_err(bad_request)?; Ok(Json(ValidateStoriesResponse { stories })) } /// Create a new story file with correct front matter in upcoming/. #[oai(path = "/workflow/stories/create", method = "post")] async fn create_story( &self, payload: Json, ) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let commit = payload.0.commit.unwrap_or(false); let story_id = create_story_file( &root, &payload.0.name, payload.0.user_story.as_deref(), payload.0.acceptance_criteria.as_deref(), commit, ) .map_err(bad_request)?; Ok(Json(CreateStoryResponse { story_id })) } /// Ensure a story can be accepted; returns an error when gates fail. #[oai(path = "/workflow/acceptance/ensure", method = "post")] async fn ensure_acceptance( &self, payload: Json, ) -> OpenApiResult> { let response = self.acceptance(payload).await?.0; if response.can_accept { return Ok(Json(true)); } let mut parts = Vec::new(); if !response.reasons.is_empty() { parts.push(response.reasons.join("; ")); } if let Some(warning) = response.warning { parts.push(warning); } let message = if parts.is_empty() { "Acceptance is blocked.".to_string() } else { format!("Acceptance is blocked: {}", parts.join("; ")) }; Err(bad_request(message)) } } /// 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}"))?; if commit { git_commit_story_file(root, &filepath, &story_id)?; } Ok(story_id) } /// 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) } // ── 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}"))?; let msg = format!("story-kit: create bug {bug_id}"); git_stage_and_commit(root, &[filepath.as_path()], &msg)?; 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}"))?; 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." )); } 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 { 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) } fn to_test_case(input: TestCasePayload) -> Result { let status = parse_test_status(&input.status)?; Ok(TestCaseResult { name: input.name, status, details: input.details, }) } fn parse_test_status(value: &str) -> Result { match value { "pass" => Ok(TestStatus::Pass), "fail" => Ok(TestStatus::Fail), other => Err(format!( "Invalid test status '{other}'. Use 'pass' or 'fail'." )), } } #[cfg(test)] mod tests { use super::*; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; #[test] fn parse_test_status_pass() { assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass); } #[test] fn parse_test_status_fail() { assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail); } #[test] fn parse_test_status_invalid() { let result = parse_test_status("unknown"); assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid test status")); } #[test] fn to_test_case_converts_pass() { let payload = TestCasePayload { name: "my_test".to_string(), status: "pass".to_string(), details: Some("all good".to_string()), }; let result = to_test_case(payload).unwrap(); assert_eq!(result.name, "my_test"); assert_eq!(result.status, TestStatus::Pass); assert_eq!(result.details, Some("all good".to_string())); } #[test] fn to_test_case_rejects_invalid_status() { let payload = TestCasePayload { name: "bad".to_string(), status: "maybe".to_string(), details: None, }; assert!(to_test_case(payload).is_err()); } #[test] fn to_review_story_all_passing() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None, }], }; let review = to_review_story("story-29", &results, None); assert!(review.can_accept); assert!(review.reasons.is_empty()); assert!(review.missing_categories.is_empty()); assert_eq!(review.summary.total, 2); assert_eq!(review.summary.passed, 2); } #[test] fn to_review_story_missing_integration() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![], }; let review = to_review_story("story-29", &results, None); assert!(!review.can_accept); assert!(review.missing_categories.contains(&"integration".to_string())); } #[test] fn to_review_story_with_failures() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Fail, details: None, }], integration: vec![TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None, }], }; let review = to_review_story("story-29", &results, None); assert!(!review.can_accept); assert_eq!(review.summary.failed, 1); } #[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 load_upcoming_parses_metadata() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write( upcoming.join("31_view_upcoming.md"), "---\nname: View Upcoming\ntest_plan: pending\n---\n# Story\n", ) .unwrap(); fs::write( upcoming.join("32_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_view_upcoming"); assert_eq!(stories[0].name.as_deref(), Some("View Upcoming")); assert_eq!(stories[1].story_id, "32_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/stories/upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join(".gitkeep"), "").unwrap(); fs::write( upcoming.join("31_story.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"); } #[test] fn validate_story_dirs_valid_files() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".story_kit/current"); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&upcoming).unwrap(); fs::write( current.join("28_todos.md"), "---\nname: Show TODOs\ntest_plan: approved\n---\n# Story\n", ) .unwrap(); fs::write( upcoming.join("36_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/current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("28_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/current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("28_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/current"); fs::create_dir_all(¤t).unwrap(); fs::write( current.join("28_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_story_number tests --- #[test] fn next_story_number_empty_dirs() { let tmp = tempfile::tempdir().unwrap(); let base = tmp.path().join(".story_kit/stories/upcoming"); fs::create_dir_all(&base).unwrap(); assert_eq!(next_story_number(tmp.path()).unwrap(), 1); } #[test] fn next_story_number_scans_all_dirs() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); let current = tmp.path().join(".story_kit/current"); let archived = tmp.path().join(".story_kit/stories/archived"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&archived).unwrap(); fs::write(upcoming.join("10_foo.md"), "").unwrap(); fs::write(current.join("20_bar.md"), "").unwrap(); fs::write(archived.join("15_baz.md"), "").unwrap(); assert_eq!(next_story_number(tmp.path()).unwrap(), 21); } #[test] fn next_story_number_no_story_dirs() { let tmp = tempfile::tempdir().unwrap(); // No .story_kit at all assert_eq!(next_story_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/stories/upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join("36_existing.md"), "").unwrap(); let number = next_story_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/stories/upcoming"); fs::create_dir_all(&upcoming).unwrap(); let filepath = upcoming.join("1_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/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/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/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/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/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/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")); } // ── Bug file helper tests ────────────────────────────────────────────────── #[test] fn next_bug_number_starts_at_1_when_empty() { let tmp = tempfile::tempdir().unwrap(); assert_eq!(next_bug_number(tmp.path()).unwrap(), 1); } #[test] fn next_bug_number_increments_from_existing() { let tmp = tempfile::tempdir().unwrap(); let bugs_dir = tmp.path().join(".story_kit/bugs"); fs::create_dir_all(&bugs_dir).unwrap(); fs::write(bugs_dir.join("bug-1-crash.md"), "").unwrap(); fs::write(bugs_dir.join("bug-3-another.md"), "").unwrap(); assert_eq!(next_bug_number(tmp.path()).unwrap(), 4); } #[test] fn next_bug_number_scans_archive_too() { let tmp = tempfile::tempdir().unwrap(); let bugs_dir = tmp.path().join(".story_kit/bugs"); let archive_dir = bugs_dir.join("archive"); fs::create_dir_all(&bugs_dir).unwrap(); fs::create_dir_all(&archive_dir).unwrap(); fs::write(archive_dir.join("bug-5-old.md"), "").unwrap(); assert_eq!(next_bug_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 bugs_dir = tmp.path().join(".story_kit/bugs"); let archive_dir = bugs_dir.join("archive"); fs::create_dir_all(&bugs_dir).unwrap(); fs::create_dir_all(&archive_dir).unwrap(); fs::write(bugs_dir.join("bug-1-open.md"), "# Bug 1: Open Bug\n").unwrap(); fs::write(archive_dir.join("bug-2-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, "bug-1-open"); assert_eq!(result[0].1, "Open Bug"); } #[test] fn list_bug_files_sorted_by_id() { let tmp = tempfile::tempdir().unwrap(); let bugs_dir = tmp.path().join(".story_kit/bugs"); fs::create_dir_all(&bugs_dir).unwrap(); fs::write(bugs_dir.join("bug-3-third.md"), "# Bug 3: Third\n").unwrap(); fs::write(bugs_dir.join("bug-1-first.md"), "# Bug 1: First\n").unwrap(); fs::write(bugs_dir.join("bug-2-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, "bug-1-first"); assert_eq!(result[1].0, "bug-2-second"); assert_eq!(result[2].0, "bug-3-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, "bug-1-login_crash"); let filepath = tmp .path() .join(".story_kit/bugs/bug-1-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/bugs/bug-1-some_bug.md"); let contents = fs::read_to_string(&filepath).unwrap(); assert!(contents.contains("- [ ] Bug is fixed and verified")); } }