From 4acf38f035d680b250b8036dc0db33582ffd921b Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 21:46:11 +0000 Subject: [PATCH] story-kit: merge 319_refactor_split_workflow_rs_into_story_bug_and_test_result_modules --- server/src/http/workflow.rs | 2117 ---------------------- server/src/http/workflow/bug_ops.rs | 586 ++++++ server/src/http/workflow/mod.rs | 745 ++++++++ server/src/http/workflow/story_ops.rs | 592 ++++++ server/src/http/workflow/test_results.rs | 261 +++ 5 files changed, 2184 insertions(+), 2117 deletions(-) delete mode 100644 server/src/http/workflow.rs create mode 100644 server/src/http/workflow/bug_ops.rs create mode 100644 server/src/http/workflow/mod.rs create mode 100644 server/src/http/workflow/story_ops.rs create mode 100644 server/src/http/workflow/test_results.rs diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs deleted file mode 100644 index 77a1658..0000000 --- a/server/src/http/workflow.rs +++ /dev/null @@ -1,2117 +0,0 @@ -use crate::agents::AgentStatus; -use crate::http::context::AppContext; -use crate::io::story_metadata::{parse_front_matter, set_front_matter_field, write_coverage_baseline}; -use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; -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, - /// Merge failure reason persisted to front matter by the mergemaster agent. - pub merge_failure: Option, - /// Active agent working on this item, if any. - pub agent: Option, - /// True when the item is held in QA for human review. - #[serde(skip_serializing_if = "Option::is_none")] - pub review_hold: Option, - /// QA mode for this item: "human", "server", or "agent". - #[serde(skip_serializing_if = "Option::is_none")] - pub qa: Option, - /// Number of retries at the current pipeline stage. - #[serde(skip_serializing_if = "Option::is_none")] - pub retry_count: Option, - /// True when the story has exceeded its retry limit and will not be auto-assigned. - #[serde(skip_serializing_if = "Option::is_none")] - pub blocked: 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 backlog: Vec, - pub current: Vec, - pub qa: Vec, - pub merge: Vec, - pub done: Vec, -} - -/// Load the full pipeline state (all 5 active stages). -pub fn load_pipeline_state(ctx: &AppContext) -> Result { - let agent_map = build_active_agent_map(ctx); - Ok(PipelineState { - backlog: load_stage_items(ctx, "1_backlog", &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)?, - done: load_stage_items(ctx, "5_done", &HashMap::new())?, - }) -} - -/// 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, merge_failure, review_hold, qa, retry_count, blocked) = match parse_front_matter(&contents) { - Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.qa.map(|m| m.as_str().to_string()), meta.retry_count, meta.blocked), - Err(e) => (None, Some(e.to_string()), None, None, None, None, None), - }; - let agent = agent_map.get(&story_id).cloned(); - stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, qa, retry_count, blocked }); - } - - 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_backlog", &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 backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); - fs::create_dir_all(&backlog_dir) - .map_err(|e| format!("Failed to create backlog directory: {e}"))?; - - let filepath = backlog_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: \"{}\"\n", name.replace('"', "\\\""))); - 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_backlog/` 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_backlog"); - fs::create_dir_all(&bugs_dir) - .map_err(|e| format!("Failed to create backlog 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("---\n"); - content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); - content.push_str("---\n\n"); - 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) -} - -// ── Spike file helpers ──────────────────────────────────────────── - -/// Create a spike file in `work/1_backlog/` with a deterministic filename. -/// -/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`). -pub fn create_spike_file( - root: &Path, - name: &str, - description: Option<&str>, -) -> Result { - let spike_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!("{spike_number}_spike_{slug}.md"); - let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); - fs::create_dir_all(&backlog_dir) - .map_err(|e| format!("Failed to create backlog directory: {e}"))?; - - let filepath = backlog_dir.join(&filename); - if filepath.exists() { - return Err(format!("Spike file already exists: {filename}")); - } - - let spike_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: \"{}\"\n", name.replace('"', "\\\""))); - content.push_str("---\n\n"); - content.push_str(&format!("# Spike {spike_number}: {name}\n\n")); - content.push_str("## Question\n\n"); - if let Some(desc) = description { - content.push_str(desc); - content.push('\n'); - } else { - content.push_str("- TBD\n"); - } - content.push('\n'); - content.push_str("## Hypothesis\n\n"); - content.push_str("- TBD\n\n"); - content.push_str("## Timebox\n\n"); - content.push_str("- TBD\n\n"); - content.push_str("## Investigation Plan\n\n"); - content.push_str("- TBD\n\n"); - content.push_str("## Findings\n\n"); - content.push_str("- TBD\n\n"); - content.push_str("## Recommendation\n\n"); - content.push_str("- TBD\n"); - - fs::write(&filepath, &content).map_err(|e| format!("Failed to write spike file: {e}"))?; - - // Watcher handles the git commit asynchronously. - - Ok(spike_id) -} - -/// Create a refactor work item file in `work/1_backlog/`. -/// -/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`). -pub fn create_refactor_file( - root: &Path, - name: &str, - description: Option<&str>, - acceptance_criteria: Option<&[String]>, -) -> Result { - let refactor_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!("{refactor_number}_refactor_{slug}.md"); - let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); - fs::create_dir_all(&backlog_dir) - .map_err(|e| format!("Failed to create backlog directory: {e}"))?; - - let filepath = backlog_dir.join(&filename); - if filepath.exists() { - return Err(format!("Refactor file already exists: {filename}")); - } - - let refactor_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: \"{}\"\n", name.replace('"', "\\\""))); - content.push_str("---\n\n"); - content.push_str(&format!("# Refactor {refactor_number}: {name}\n\n")); - content.push_str("## Current State\n\n"); - content.push_str("- TBD\n\n"); - content.push_str("## Desired State\n\n"); - if let Some(desc) = description { - content.push_str(desc); - content.push('\n'); - } else { - content.push_str("- TBD\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("- [ ] Refactoring complete and all tests pass\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 refactor file: {e}"))?; - - // Watcher handles the git commit asynchronously. - - Ok(refactor_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_backlog/` matching the `_bug_` naming pattern. -/// -/// Returns a sorted list of `(bug_id, name)` pairs. -pub fn list_bug_files(root: &Path) -> Result, String> { - let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); - if !backlog_dir.exists() { - return Ok(Vec::new()); - } - - let mut bugs = Vec::new(); - for entry in - fs::read_dir(&backlog_dir).map_err(|e| format!("Failed to read backlog 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) -} - -/// Returns true if the item stem (filename without extension) is a refactor item. -/// Refactor items follow the pattern: {N}_refactor_{slug} -fn is_refactor_item(stem: &str) -> bool { - let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); - after_num.starts_with("_refactor_") -} - -/// List all open refactors — files in `work/1_backlog/` matching the `_refactor_` naming pattern. -/// -/// Returns a sorted list of `(refactor_id, name)` pairs. -pub fn list_refactor_files(root: &Path) -> Result, String> { - let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); - if !backlog_dir.exists() { - return Ok(Vec::new()); - } - - let mut refactors = Vec::new(); - for entry in fs::read_dir(&backlog_dir) - .map_err(|e| format!("Failed to read backlog 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())?; - - if !is_refactor_item(stem) { - continue; - } - - let refactor_id = stem.to_string(); - let name = fs::read_to_string(&path) - .ok() - .and_then(|contents| parse_front_matter(&contents).ok()) - .and_then(|m| m.name) - .unwrap_or_else(|| refactor_id.clone()); - refactors.push((refactor_id, name)); - } - - refactors.sort_by(|a, b| a.0.cmp(&b.0)); - Ok(refactors) -} - -/// Locate a work item file by searching all active pipeline stages. -/// -/// Searches in priority order: 2_current, 1_backlog, 3_qa, 4_merge, 5_done, 6_archived. -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"); - for stage in &["2_current", "1_backlog", "3_qa", "4_merge", "5_done", "6_archived"] { - let path = sk.join(stage).join(&filename); - if path.exists() { - return Ok(path); - } - } - Err(format!( - "Story '{story_id}' not found in any pipeline stage." - )) -} - -/// 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(()) -} - -/// Add a new acceptance criterion to a story file. -/// -/// Appends `- [ ] {criterion}` after the last existing criterion line in the -/// "## Acceptance Criteria" section, or directly after the section heading if -/// the section is empty. The filesystem watcher auto-commits the change. -pub fn add_criterion_to_file( - project_root: &Path, - story_id: &str, - criterion: &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 lines: Vec<&str> = contents.lines().collect(); - let mut in_ac_section = false; - let mut ac_section_start: Option = None; - let mut last_criterion_line: Option = None; - - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - if trimmed == "## Acceptance Criteria" { - in_ac_section = true; - ac_section_start = Some(i); - continue; - } - if in_ac_section { - if trimmed.starts_with("## ") { - break; - } - if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") { - last_criterion_line = Some(i); - } - } - } - - let insert_after = last_criterion_line - .or(ac_section_start) - .ok_or_else(|| { - format!("Story '{story_id}' has no '## Acceptance Criteria' section.") - })?; - - let mut new_lines: Vec = lines.iter().map(|s| s.to_string()).collect(); - new_lines.insert(insert_after + 1, format!("- [ ] {criterion}")); - - 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(()) -} - -/// Replace the content of a named `## Section` in a story file. -/// -/// Finds the first occurrence of `## {section_name}` and replaces everything -/// until the next `##` heading (or end of file) with the provided text. -/// Returns an error if the section is not found. -fn replace_section_content(content: &str, section_name: &str, new_text: &str) -> Result { - let lines: Vec<&str> = content.lines().collect(); - let heading = format!("## {section_name}"); - - let mut section_start: Option = None; - let mut section_end: Option = None; - - for (i, line) in lines.iter().enumerate() { - let trimmed = line.trim(); - if trimmed == heading { - section_start = Some(i); - continue; - } - if section_start.is_some() && trimmed.starts_with("## ") { - section_end = Some(i); - break; - } - } - - let section_start = - section_start.ok_or_else(|| format!("Section '{heading}' not found in story file."))?; - - let mut new_lines: Vec = Vec::new(); - // Keep everything up to and including the section heading. - for line in lines.iter().take(section_start + 1) { - new_lines.push(line.to_string()); - } - // Blank line, new content, blank line. - new_lines.push(String::new()); - new_lines.push(new_text.to_string()); - new_lines.push(String::new()); - // Resume from the next section heading (or EOF). - let resume_from = section_end.unwrap_or(lines.len()); - for line in lines.iter().skip(resume_from) { - new_lines.push(line.to_string()); - } - - let mut new_str = new_lines.join("\n"); - if content.ends_with('\n') { - new_str.push('\n'); - } - Ok(new_str) -} - -/// Update the user story text and/or description in a story file. -/// -/// At least one of `user_story` or `description` must be provided. -/// Replaces the content of the corresponding `##` section in place. -/// The filesystem watcher auto-commits the change. -pub fn update_story_in_file( - project_root: &Path, - story_id: &str, - user_story: Option<&str>, - description: Option<&str>, - front_matter: Option<&HashMap>, -) -> Result<(), String> { - let has_front_matter_updates = front_matter.map(|m| !m.is_empty()).unwrap_or(false); - if user_story.is_none() && description.is_none() && !has_front_matter_updates { - return Err( - "At least one of 'user_story', 'description', or 'front_matter' must be provided." - .to_string(), - ); - } - - let filepath = find_story_file(project_root, story_id)?; - let mut contents = fs::read_to_string(&filepath) - .map_err(|e| format!("Failed to read story file: {e}"))?; - - if let Some(fields) = front_matter { - for (key, value) in fields { - let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', "")); - contents = set_front_matter_field(&contents, key, &yaml_value); - } - } - - if let Some(us) = user_story { - contents = replace_section_content(&contents, "User Story", us)?; - } - if let Some(desc) = description { - contents = replace_section_content(&contents, "Description", desc)?; - } - - fs::write(&filepath, &contents) - .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_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_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) -} - -// ── Test result file persistence ────────────────────────────────── - -const TEST_RESULTS_MARKER: &str = "\n\n")); - - // Unit tests - let (unit_pass, unit_fail) = count_pass_fail(&results.unit); - s.push_str(&format!( - "### Unit Tests ({unit_pass} passed, {unit_fail} failed)\n\n" - )); - if results.unit.is_empty() { - s.push_str("*No unit tests recorded.*\n"); - } else { - for t in &results.unit { - s.push_str(&format_test_line(t)); - } - } - s.push('\n'); - - // Integration tests - let (int_pass, int_fail) = count_pass_fail(&results.integration); - s.push_str(&format!( - "### Integration Tests ({int_pass} passed, {int_fail} failed)\n\n" - )); - if results.integration.is_empty() { - s.push_str("*No integration tests recorded.*\n"); - } else { - for t in &results.integration { - s.push_str(&format_test_line(t)); - } - } - - s -} - -fn count_pass_fail(tests: &[TestCaseResult]) -> (usize, usize) { - let pass = tests.iter().filter(|t| t.status == TestStatus::Pass).count(); - (pass, tests.len() - pass) -} - -fn format_test_line(t: &TestCaseResult) -> String { - let icon = if t.status == TestStatus::Pass { "✅" } else { "❌" }; - match &t.details { - Some(d) if !d.is_empty() => format!("- {icon} {} — {d}\n", t.name), - _ => format!("- {icon} {}\n", t.name), - } -} - -/// Replace the `## Test Results` section in `contents` with `new_section`, -/// or append it if not present. -fn replace_or_append_section(contents: &str, header: &str, new_section: &str) -> String { - let lines: Vec<&str> = contents.lines().collect(); - let header_trimmed = header.trim(); - - // Find the start of the existing section - let section_start = lines.iter().position(|l| l.trim() == header_trimmed); - - if let Some(start) = section_start { - // Find the next `##` heading after the section start (the end of this section) - let section_end = lines[start + 1..] - .iter() - .position(|l| { - let t = l.trim(); - t.starts_with("## ") && t != header_trimmed - }) - .map(|i| start + 1 + i) - .unwrap_or(lines.len()); - - let mut result = lines[..start].join("\n"); - if !result.is_empty() { - result.push('\n'); - } - result.push_str(new_section); - if section_end < lines.len() { - result.push('\n'); - result.push_str(&lines[section_end..].join("\n")); - } - if contents.ends_with('\n') { - result.push('\n'); - } - result - } else { - // Append at the end - let mut result = contents.trim_end_matches('\n').to_string(); - result.push_str("\n\n"); - result.push_str(new_section); - if !result.ends_with('\n') { - result.push('\n'); - } - result - } -} - -/// Parse `StoryTestResults` from the JSON embedded in the `## Test Results` section. -fn parse_test_results_from_contents(contents: &str) -> Option { - for line in contents.lines() { - let trimmed = line.trim(); - if let Some(rest) = trimmed.strip_prefix(TEST_RESULTS_MARKER) { - // rest looks like: ` {...} -->` - if let Some(json_end) = rest.rfind("-->") { - let json_str = rest[..json_end].trim(); - if let Ok(results) = serde_json::from_str::(json_str) { - return Some(results); - } - } - } - } - None -} - -pub fn validate_story_dirs( - root: &std::path::Path, -) -> Result, String> { - let mut results = Vec::new(); - - // Directories to validate: work/2_current/ + work/1_backlog/ - let dirs_to_validate: Vec = vec![ - root.join(".story_kit").join("work").join("2_current"), - root.join(".story_kit").join("work").join("1_backlog"), - ]; - - 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 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_pipeline_state_loads_all_stages() { - let tmp = tempfile::tempdir().unwrap(); - let root = tmp.path().to_path_buf(); - - for (stage, id) in &[ - ("1_backlog", "10_story_upcoming"), - ("2_current", "20_story_current"), - ("3_qa", "30_story_qa"), - ("4_merge", "40_story_merge"), - ("5_done", "50_story_done"), - ] { - let dir = root.join(".story_kit").join("work").join(stage); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join(format!("{id}.md")), - format!("---\nname: {id}\n---\n"), - ) - .unwrap(); - } - - let ctx = crate::http::context::AppContext::new_test(root); - let state = load_pipeline_state(&ctx).unwrap(); - - assert_eq!(state.backlog.len(), 1); - assert_eq!(state.backlog[0].story_id, "10_story_upcoming"); - - assert_eq!(state.current.len(), 1); - assert_eq!(state.current[0].story_id, "20_story_current"); - - assert_eq!(state.qa.len(), 1); - assert_eq!(state.qa[0].story_id, "30_story_qa"); - - assert_eq!(state.merge.len(), 1); - assert_eq!(state.merge[0].story_id, "40_story_merge"); - - assert_eq!(state.done.len(), 1); - assert_eq!(state.done[0].story_id, "50_story_done"); - } - - #[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\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\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\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 backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - fs::write( - backlog.join("31_story_view_upcoming.md"), - "---\nname: View Upcoming\n---\n# Story\n", - ) - .unwrap(); - fs::write( - backlog.join("32_story_worktree.md"), - "---\nname: Worktree Orchestration\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 backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - fs::write(backlog.join(".gitkeep"), "").unwrap(); - fs::write( - backlog.join("31_story_example.md"), - "---\nname: A Story\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 backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(¤t).unwrap(); - fs::create_dir_all(&backlog).unwrap(); - fs::write( - current.join("28_story_todos.md"), - "---\nname: Show TODOs\n---\n# Story\n", - ) - .unwrap(); - fs::write( - backlog.join("36_story_front_matter.md"), - "---\nname: Enforce Front Matter\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")); - } - - #[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_backlog"); - 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 backlog = tmp.path().join(".story_kit/work/1_backlog"); - let current = tmp.path().join(".story_kit/work/2_current"); - let archived = tmp.path().join(".story_kit/work/5_done"); - fs::create_dir_all(&backlog).unwrap(); - fs::create_dir_all(¤t).unwrap(); - fs::create_dir_all(&archived).unwrap(); - fs::write(backlog.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 backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - fs::write(backlog.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 = backlog.join(&filename); - - let mut content = String::new(); - content.push_str("---\n"); - content.push_str("name: \"My New Feature\"\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\"\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_with_colon_in_name_produces_valid_yaml() { - let tmp = tempfile::tempdir().unwrap(); - let name = "Server-owned agent completion: remove report_completion dependency"; - let result = create_story_file(tmp.path(), name, None, None, false); - assert!(result.is_ok(), "create_story_file failed: {result:?}"); - - let backlog = tmp.path().join(".story_kit/work/1_backlog"); - let story_id = result.unwrap(); - let filename = format!("{story_id}.md"); - let contents = fs::read_to_string(backlog.join(&filename)).unwrap(); - - let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); - assert_eq!(meta.name.as_deref(), Some(name)); - } - - #[test] - fn create_story_rejects_duplicate() { - let tmp = tempfile::tempdir().unwrap(); - let backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - - let filepath = backlog.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\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")); - } - - #[test] - fn find_story_file_searches_current_then_backlog() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - let backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(¤t).unwrap(); - fs::create_dir_all(&backlog).unwrap(); - - // Only in backlog - fs::write(backlog.join("6_test.md"), "").unwrap(); - let found = find_story_file(tmp.path(), "6_test").unwrap(); - assert!(found.ends_with("1_backlog/6_test.md") || found.ends_with("1_backlog\\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")); - } - - // ── add_criterion_to_file tests ─────────────────────────────────────────── - - fn story_with_ac_section(criteria: &[&str]) -> String { - let mut s = "---\nname: Test\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n".to_string(); - for c in criteria { - s.push_str(&format!("- [ ] {c}\n")); - } - s.push_str("\n## Out of Scope\n\n- N/A\n"); - s - } - - #[test] - fn add_criterion_appends_after_last_criterion() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let filepath = current.join("10_test.md"); - fs::write(&filepath, story_with_ac_section(&["First", "Second"])).unwrap(); - - add_criterion_to_file(tmp.path(), "10_test", "Third").unwrap(); - - let contents = fs::read_to_string(&filepath).unwrap(); - assert!(contents.contains("- [ ] First\n")); - assert!(contents.contains("- [ ] Second\n")); - assert!(contents.contains("- [ ] Third\n")); - // Third should come after Second - let pos_second = contents.find("- [ ] Second").unwrap(); - let pos_third = contents.find("- [ ] Third").unwrap(); - assert!(pos_third > pos_second, "Third should appear after Second"); - } - - #[test] - fn add_criterion_to_empty_section() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let filepath = current.join("11_test.md"); - let content = "---\nname: Test\n---\n\n## Acceptance Criteria\n\n## Out of Scope\n\n- N/A\n"; - fs::write(&filepath, content).unwrap(); - - add_criterion_to_file(tmp.path(), "11_test", "New AC").unwrap(); - - let contents = fs::read_to_string(&filepath).unwrap(); - assert!(contents.contains("- [ ] New AC\n"), "criterion should be present"); - } - - #[test] - fn add_criterion_missing_section_returns_error() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let filepath = current.join("12_test.md"); - fs::write(&filepath, "---\nname: Test\n---\n\nNo AC section here.\n").unwrap(); - - let result = add_criterion_to_file(tmp.path(), "12_test", "X"); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Acceptance Criteria")); - } - - // ── update_story_in_file tests ───────────────────────────────────────────── - - #[test] - fn update_story_replaces_user_story_section() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let filepath = current.join("20_test.md"); - let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n"; - fs::write(&filepath, content).unwrap(); - - update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None, None).unwrap(); - - let result = fs::read_to_string(&filepath).unwrap(); - assert!(result.contains("New user story text"), "new text should be present"); - assert!(!result.contains("Old text"), "old text should be replaced"); - assert!(result.contains("## Acceptance Criteria"), "other sections preserved"); - } - - #[test] - fn update_story_replaces_description_section() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let filepath = current.join("21_test.md"); - let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n"; - fs::write(&filepath, content).unwrap(); - - update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap(); - - let result = fs::read_to_string(&filepath).unwrap(); - assert!(result.contains("New description"), "new description present"); - assert!(!result.contains("Old description"), "old description replaced"); - } - - #[test] - fn update_story_no_args_returns_error() { - 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("22_test.md"), "---\nname: T\n---\n").unwrap(); - - let result = update_story_in_file(tmp.path(), "22_test", None, None, None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("At least one")); - } - - #[test] - fn update_story_missing_section_returns_error() { - 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("23_test.md"), - "---\nname: T\n---\n\nNo sections here.\n", - ) - .unwrap(); - - let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None, None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("User Story")); - } - - #[test] - fn update_story_sets_agent_front_matter_field() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let filepath = current.join("24_test.md"); - fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap(); - - let mut fields = HashMap::new(); - fields.insert("agent".to_string(), "dev".to_string()); - update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap(); - - let result = fs::read_to_string(&filepath).unwrap(); - assert!(result.contains("agent: \"dev\""), "agent field should be set"); - assert!(result.contains("name: T"), "name field preserved"); - } - - #[test] - fn update_story_sets_arbitrary_front_matter_fields() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let filepath = current.join("25_test.md"); - fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap(); - - let mut fields = HashMap::new(); - fields.insert("qa".to_string(), "human".to_string()); - fields.insert("priority".to_string(), "high".to_string()); - update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap(); - - let result = fs::read_to_string(&filepath).unwrap(); - assert!(result.contains("qa: \"human\""), "qa field should be set"); - assert!(result.contains("priority: \"high\""), "priority field should be set"); - assert!(result.contains("name: T"), "name field preserved"); - } - - #[test] - fn update_story_front_matter_only_no_section_required() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - // File without a User Story section — front matter update should succeed - let filepath = current.join("26_test.md"); - fs::write(&filepath, "---\nname: T\n---\n\nNo sections here.\n").unwrap(); - - let mut fields = HashMap::new(); - fields.insert("agent".to_string(), "dev".to_string()); - let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields)); - assert!(result.is_ok(), "front-matter-only update should not require body sections"); - - let contents = fs::read_to_string(&filepath).unwrap(); - assert!(contents.contains("agent: \"dev\"")); - } - - // ── 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 backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - fs::write(backlog.join("1_bug_crash.md"), "").unwrap(); - fs::write(backlog.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 backlog = tmp.path().join(".story_kit/work/1_backlog"); - let archived = tmp.path().join(".story_kit/work/5_done"); - fs::create_dir_all(&backlog).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 backlog_dir = tmp.path().join(".story_kit/work/1_backlog"); - let archived_dir = tmp.path().join(".story_kit/work/5_done"); - fs::create_dir_all(&backlog_dir).unwrap(); - fs::create_dir_all(&archived_dir).unwrap(); - fs::write(backlog_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 backlog_dir = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(&backlog_dir).unwrap(); - fs::write(backlog_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap(); - fs::write(backlog_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap(); - fs::write(backlog_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_backlog/1_bug_login_crash.md"); - assert!(filepath.exists()); - let contents = fs::read_to_string(&filepath).unwrap(); - assert!( - contents.starts_with("---\nname: \"Login Crash\"\n---"), - "bug file must start with YAML front matter" - ); - 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_backlog/1_bug_some_bug.md"); - let contents = fs::read_to_string(&filepath).unwrap(); - assert!( - contents.starts_with("---\nname: \"Some Bug\"\n---"), - "bug file must have YAML front matter" - ); - assert!(contents.contains("- [ ] Bug is fixed and verified")); - } - - // ── create_spike_file tests ──────────────────────────────────────────────── - - #[test] - fn create_spike_file_writes_correct_content() { - let tmp = tempfile::tempdir().unwrap(); - - let spike_id = - create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None).unwrap(); - - assert_eq!(spike_id, "1_spike_filesystem_watcher_architecture"); - - let filepath = tmp - .path() - .join(".story_kit/work/1_backlog/1_spike_filesystem_watcher_architecture.md"); - assert!(filepath.exists()); - let contents = fs::read_to_string(&filepath).unwrap(); - assert!( - contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"), - "spike file must start with YAML front matter" - ); - assert!(contents.contains("# Spike 1: Filesystem Watcher Architecture")); - assert!(contents.contains("## Question")); - assert!(contents.contains("## Hypothesis")); - assert!(contents.contains("## Timebox")); - assert!(contents.contains("## Investigation Plan")); - assert!(contents.contains("## Findings")); - assert!(contents.contains("## Recommendation")); - } - - #[test] - fn create_spike_file_uses_description_when_provided() { - let tmp = tempfile::tempdir().unwrap(); - let description = "What is the best approach for watching filesystem events?"; - - create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap(); - - let filepath = - tmp.path().join(".story_kit/work/1_backlog/1_spike_fs_watcher_spike.md"); - let contents = fs::read_to_string(&filepath).unwrap(); - assert!(contents.contains(description)); - } - - #[test] - fn create_spike_file_uses_placeholder_when_no_description() { - let tmp = tempfile::tempdir().unwrap(); - create_spike_file(tmp.path(), "My Spike", None).unwrap(); - - let filepath = tmp.path().join(".story_kit/work/1_backlog/1_spike_my_spike.md"); - let contents = fs::read_to_string(&filepath).unwrap(); - // Should have placeholder TBD in Question section - assert!(contents.contains("## Question\n\n- TBD\n")); - } - - #[test] - fn create_spike_file_rejects_empty_name() { - let tmp = tempfile::tempdir().unwrap(); - let result = create_spike_file(tmp.path(), "!!!", None); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("alphanumeric")); - } - - #[test] - fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() { - let tmp = tempfile::tempdir().unwrap(); - let name = "Spike: compare \"fast\" vs slow encoders"; - let result = create_spike_file(tmp.path(), name, None); - assert!(result.is_ok(), "create_spike_file failed: {result:?}"); - - let backlog = tmp.path().join(".story_kit/work/1_backlog"); - let spike_id = result.unwrap(); - let filename = format!("{spike_id}.md"); - let contents = fs::read_to_string(backlog.join(&filename)).unwrap(); - - let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); - assert_eq!(meta.name.as_deref(), Some(name)); - } - - #[test] - fn create_spike_file_increments_from_existing_items() { - let tmp = tempfile::tempdir().unwrap(); - let backlog = tmp.path().join(".story_kit/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - fs::write(backlog.join("5_story_existing.md"), "").unwrap(); - - let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap(); - assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}"); - } - - // ── Test result file persistence ────────────────────────────── - - use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; - - fn make_results() -> StoryTestResults { - StoryTestResults { - unit: vec![ - TestCaseResult { name: "unit-pass".to_string(), status: TestStatus::Pass, details: None }, - TestCaseResult { name: "unit-fail".to_string(), status: TestStatus::Fail, details: Some("assertion failed".to_string()) }, - ], - integration: vec![ - TestCaseResult { name: "int-pass".to_string(), status: TestStatus::Pass, details: None }, - ], - } - } - - #[test] - fn write_and_read_test_results_roundtrip() { - 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("1_story_test.md"), "---\nname: Test\n---\n# Story\n").unwrap(); - - let results = make_results(); - write_test_results_to_story_file(tmp.path(), "1_story_test", &results).unwrap(); - - let read_back = read_test_results_from_story_file(tmp.path(), "1_story_test") - .expect("should read back results"); - assert_eq!(read_back.unit.len(), 2); - assert_eq!(read_back.integration.len(), 1); - assert_eq!(read_back.unit[0].name, "unit-pass"); - assert_eq!(read_back.unit[1].status, TestStatus::Fail); - assert_eq!(read_back.unit[1].details.as_deref(), Some("assertion failed")); - } - - #[test] - fn write_test_results_creates_readable_section() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let story_path = current.join("2_story_check.md"); - fs::write(&story_path, "---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n").unwrap(); - - let results = make_results(); - write_test_results_to_story_file(tmp.path(), "2_story_check", &results).unwrap(); - - let contents = fs::read_to_string(&story_path).unwrap(); - assert!(contents.contains("## Test Results")); - assert!(contents.contains("✅ unit-pass")); - assert!(contents.contains("❌ unit-fail")); - assert!(contents.contains("assertion failed")); - assert!(contents.contains("story-kit-test-results:")); - // Original content still present - assert!(contents.contains("## Acceptance Criteria")); - } - - #[test] - fn write_test_results_overwrites_existing_section() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/work/2_current"); - fs::create_dir_all(¤t).unwrap(); - let story_path = current.join("3_story_overwrite.md"); - fs::write( - &story_path, - "---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", - ) - .unwrap(); - - let results = make_results(); - write_test_results_to_story_file(tmp.path(), "3_story_overwrite", &results).unwrap(); - - let contents = fs::read_to_string(&story_path).unwrap(); - assert!(contents.contains("✅ unit-pass")); - // Should have only one ## Test Results header - let count = contents.matches("## Test Results").count(); - assert_eq!(count, 1, "should have exactly one ## Test Results section"); - } - - #[test] - fn read_test_results_returns_none_when_no_section() { - 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("4_story_empty.md"), "---\nname: Empty\n---\n# Story\n").unwrap(); - - let result = read_test_results_from_story_file(tmp.path(), "4_story_empty"); - assert!(result.is_none()); - } - - #[test] - fn read_test_results_returns_none_for_unknown_story() { - let tmp = tempfile::tempdir().unwrap(); - let result = read_test_results_from_story_file(tmp.path(), "99_story_unknown"); - assert!(result.is_none()); - } - - #[test] - fn write_test_results_finds_story_in_any_stage() { - let tmp = tempfile::tempdir().unwrap(); - let qa_dir = tmp.path().join(".story_kit/work/3_qa"); - fs::create_dir_all(&qa_dir).unwrap(); - fs::write(qa_dir.join("5_story_qa.md"), "---\nname: QA Story\n---\n# Story\n").unwrap(); - - let results = StoryTestResults { - unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None }], - integration: vec![], - }; - write_test_results_to_story_file(tmp.path(), "5_story_qa", &results).unwrap(); - - let read_back = read_test_results_from_story_file(tmp.path(), "5_story_qa").unwrap(); - assert_eq!(read_back.unit.len(), 1); - } - - #[test] - fn write_coverage_baseline_to_story_file_updates_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("6_story_cov.md"), "---\nname: Cov Story\n---\n# Story\n").unwrap(); - - write_coverage_baseline_to_story_file(tmp.path(), "6_story_cov", 75.4).unwrap(); - - let contents = fs::read_to_string(current.join("6_story_cov.md")).unwrap(); - assert!(contents.contains("coverage_baseline: 75.4%"), "got: {contents}"); - } - - #[test] - fn write_coverage_baseline_to_story_file_silent_on_missing_story() { - let tmp = tempfile::tempdir().unwrap(); - // Story doesn't exist — should succeed silently - let result = write_coverage_baseline_to_story_file(tmp.path(), "99_story_missing", 50.0); - assert!(result.is_ok()); - } - - #[test] - fn replace_or_append_section_appends_when_absent() { - let contents = "---\nname: T\n---\n# Story\n"; - let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nfoo\n"); - assert!(new.contains("## Test Results")); - assert!(new.contains("foo")); - assert!(new.contains("# Story")); - } - - #[test] - fn replace_or_append_section_replaces_existing() { - let contents = "# Story\n\n## Test Results\n\nold content\n\n## Other\n\nother content\n"; - let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nnew content\n"); - assert!(new.contains("new content")); - assert!(!new.contains("old content")); - assert!(new.contains("## Other")); - } -} diff --git a/server/src/http/workflow/bug_ops.rs b/server/src/http/workflow/bug_ops.rs new file mode 100644 index 0000000..494b3ae --- /dev/null +++ b/server/src/http/workflow/bug_ops.rs @@ -0,0 +1,586 @@ +use crate::io::story_metadata::parse_front_matter; +use std::fs; +use std::path::Path; + +use super::{next_item_number, slugify_name}; + +/// Create a bug file in `work/1_backlog/` 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_backlog"); + fs::create_dir_all(&bugs_dir) + .map_err(|e| format!("Failed to create backlog 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("---\n"); + content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); + content.push_str("---\n\n"); + 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) +} + +/// Create a spike file in `work/1_backlog/` with a deterministic filename. +/// +/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`). +pub fn create_spike_file( + root: &Path, + name: &str, + description: Option<&str>, +) -> Result { + let spike_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!("{spike_number}_spike_{slug}.md"); + let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); + fs::create_dir_all(&backlog_dir) + .map_err(|e| format!("Failed to create backlog directory: {e}"))?; + + let filepath = backlog_dir.join(&filename); + if filepath.exists() { + return Err(format!("Spike file already exists: {filename}")); + } + + let spike_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: \"{}\"\n", name.replace('"', "\\\""))); + content.push_str("---\n\n"); + content.push_str(&format!("# Spike {spike_number}: {name}\n\n")); + content.push_str("## Question\n\n"); + if let Some(desc) = description { + content.push_str(desc); + content.push('\n'); + } else { + content.push_str("- TBD\n"); + } + content.push('\n'); + content.push_str("## Hypothesis\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Timebox\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Investigation Plan\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Findings\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Recommendation\n\n"); + content.push_str("- TBD\n"); + + fs::write(&filepath, &content).map_err(|e| format!("Failed to write spike file: {e}"))?; + + // Watcher handles the git commit asynchronously. + + Ok(spike_id) +} + +/// Create a refactor work item file in `work/1_backlog/`. +/// +/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`). +pub fn create_refactor_file( + root: &Path, + name: &str, + description: Option<&str>, + acceptance_criteria: Option<&[String]>, +) -> Result { + let refactor_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!("{refactor_number}_refactor_{slug}.md"); + let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); + fs::create_dir_all(&backlog_dir) + .map_err(|e| format!("Failed to create backlog directory: {e}"))?; + + let filepath = backlog_dir.join(&filename); + if filepath.exists() { + return Err(format!("Refactor file already exists: {filename}")); + } + + let refactor_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: \"{}\"\n", name.replace('"', "\\\""))); + content.push_str("---\n\n"); + content.push_str(&format!("# Refactor {refactor_number}: {name}\n\n")); + content.push_str("## Current State\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Desired State\n\n"); + if let Some(desc) = description { + content.push_str(desc); + content.push('\n'); + } else { + content.push_str("- TBD\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("- [ ] Refactoring complete and all tests pass\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 refactor file: {e}"))?; + + // Watcher handles the git commit asynchronously. + + Ok(refactor_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_backlog/` matching the `_bug_` naming pattern. +/// +/// Returns a sorted list of `(bug_id, name)` pairs. +pub fn list_bug_files(root: &Path) -> Result, String> { + let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); + if !backlog_dir.exists() { + return Ok(Vec::new()); + } + + let mut bugs = Vec::new(); + for entry in + fs::read_dir(&backlog_dir).map_err(|e| format!("Failed to read backlog 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) +} + +/// Returns true if the item stem (filename without extension) is a refactor item. +/// Refactor items follow the pattern: {N}_refactor_{slug} +fn is_refactor_item(stem: &str) -> bool { + let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); + after_num.starts_with("_refactor_") +} + +/// List all open refactors — files in `work/1_backlog/` matching the `_refactor_` naming pattern. +/// +/// Returns a sorted list of `(refactor_id, name)` pairs. +pub fn list_refactor_files(root: &Path) -> Result, String> { + let backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); + if !backlog_dir.exists() { + return Ok(Vec::new()); + } + + let mut refactors = Vec::new(); + for entry in fs::read_dir(&backlog_dir) + .map_err(|e| format!("Failed to read backlog 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())?; + + if !is_refactor_item(stem) { + continue; + } + + let refactor_id = stem.to_string(); + let name = fs::read_to_string(&path) + .ok() + .and_then(|contents| parse_front_matter(&contents).ok()) + .and_then(|m| m.name) + .unwrap_or_else(|| refactor_id.clone()); + refactors.push((refactor_id, name)); + } + + refactors.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(refactors) +} + +#[cfg(test)] +mod tests { + use super::*; + + 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(); + } + + // ── Bug file helper tests ────────────────────────────────────────────────── + + #[test] + fn next_item_number_starts_at_1_when_empty_bugs() { + let tmp = tempfile::tempdir().unwrap(); + assert_eq!(super::super::next_item_number(tmp.path()).unwrap(), 1); + } + + #[test] + fn next_item_number_increments_from_existing_bugs() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write(backlog.join("1_bug_crash.md"), "").unwrap(); + fs::write(backlog.join("3_bug_another.md"), "").unwrap(); + assert_eq!(super::super::next_item_number(tmp.path()).unwrap(), 4); + } + + #[test] + fn next_item_number_scans_archived_too() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + let archived = tmp.path().join(".story_kit/work/5_done"); + fs::create_dir_all(&backlog).unwrap(); + fs::create_dir_all(&archived).unwrap(); + fs::write(archived.join("5_bug_old.md"), "").unwrap(); + assert_eq!(super::super::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 backlog_dir = tmp.path().join(".story_kit/work/1_backlog"); + let archived_dir = tmp.path().join(".story_kit/work/5_done"); + fs::create_dir_all(&backlog_dir).unwrap(); + fs::create_dir_all(&archived_dir).unwrap(); + fs::write(backlog_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 backlog_dir = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog_dir).unwrap(); + fs::write(backlog_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap(); + fs::write(backlog_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap(); + fs::write(backlog_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_backlog/1_bug_login_crash.md"); + assert!(filepath.exists()); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!( + contents.starts_with("---\nname: \"Login Crash\"\n---"), + "bug file must start with YAML front matter" + ); + 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_backlog/1_bug_some_bug.md"); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!( + contents.starts_with("---\nname: \"Some Bug\"\n---"), + "bug file must have YAML front matter" + ); + assert!(contents.contains("- [ ] Bug is fixed and verified")); + } + + // ── create_spike_file tests ──────────────────────────────────────────────── + + #[test] + fn create_spike_file_writes_correct_content() { + let tmp = tempfile::tempdir().unwrap(); + + let spike_id = + create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None).unwrap(); + + assert_eq!(spike_id, "1_spike_filesystem_watcher_architecture"); + + let filepath = tmp + .path() + .join(".story_kit/work/1_backlog/1_spike_filesystem_watcher_architecture.md"); + assert!(filepath.exists()); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!( + contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"), + "spike file must start with YAML front matter" + ); + assert!(contents.contains("# Spike 1: Filesystem Watcher Architecture")); + assert!(contents.contains("## Question")); + assert!(contents.contains("## Hypothesis")); + assert!(contents.contains("## Timebox")); + assert!(contents.contains("## Investigation Plan")); + assert!(contents.contains("## Findings")); + assert!(contents.contains("## Recommendation")); + } + + #[test] + fn create_spike_file_uses_description_when_provided() { + let tmp = tempfile::tempdir().unwrap(); + let description = "What is the best approach for watching filesystem events?"; + + create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap(); + + let filepath = + tmp.path().join(".story_kit/work/1_backlog/1_spike_fs_watcher_spike.md"); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains(description)); + } + + #[test] + fn create_spike_file_uses_placeholder_when_no_description() { + let tmp = tempfile::tempdir().unwrap(); + create_spike_file(tmp.path(), "My Spike", None).unwrap(); + + let filepath = tmp.path().join(".story_kit/work/1_backlog/1_spike_my_spike.md"); + let contents = fs::read_to_string(&filepath).unwrap(); + // Should have placeholder TBD in Question section + assert!(contents.contains("## Question\n\n- TBD\n")); + } + + #[test] + fn create_spike_file_rejects_empty_name() { + let tmp = tempfile::tempdir().unwrap(); + let result = create_spike_file(tmp.path(), "!!!", None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("alphanumeric")); + } + + #[test] + fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() { + let tmp = tempfile::tempdir().unwrap(); + let name = "Spike: compare \"fast\" vs slow encoders"; + let result = create_spike_file(tmp.path(), name, None); + assert!(result.is_ok(), "create_spike_file failed: {result:?}"); + + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + let spike_id = result.unwrap(); + let filename = format!("{spike_id}.md"); + let contents = fs::read_to_string(backlog.join(&filename)).unwrap(); + + let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); + assert_eq!(meta.name.as_deref(), Some(name)); + } + + #[test] + fn create_spike_file_increments_from_existing_items() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write(backlog.join("5_story_existing.md"), "").unwrap(); + + let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap(); + assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}"); + } +} diff --git a/server/src/http/workflow/mod.rs b/server/src/http/workflow/mod.rs new file mode 100644 index 0000000..ca15c43 --- /dev/null +++ b/server/src/http/workflow/mod.rs @@ -0,0 +1,745 @@ +mod bug_ops; +mod story_ops; +mod test_results; + +pub use bug_ops::{ + create_bug_file, create_refactor_file, create_spike_file, list_bug_files, list_refactor_files, +}; +pub use story_ops::{ + add_criterion_to_file, check_criterion_in_file, create_story_file, update_story_in_file, +}; +pub use test_results::{ + read_test_results_from_story_file, write_coverage_baseline_to_story_file, + write_test_results_to_story_file, +}; + +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, + /// Merge failure reason persisted to front matter by the mergemaster agent. + pub merge_failure: Option, + /// Active agent working on this item, if any. + pub agent: Option, + /// True when the item is held in QA for human review. + #[serde(skip_serializing_if = "Option::is_none")] + pub review_hold: Option, + /// QA mode for this item: "human", "server", or "agent". + #[serde(skip_serializing_if = "Option::is_none")] + pub qa: Option, + /// Number of retries at the current pipeline stage. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_count: Option, + /// True when the story has exceeded its retry limit and will not be auto-assigned. + #[serde(skip_serializing_if = "Option::is_none")] + pub blocked: 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 backlog: Vec, + pub current: Vec, + pub qa: Vec, + pub merge: Vec, + pub done: Vec, +} + +/// Load the full pipeline state (all 5 active stages). +pub fn load_pipeline_state(ctx: &AppContext) -> Result { + let agent_map = build_active_agent_map(ctx); + Ok(PipelineState { + backlog: load_stage_items(ctx, "1_backlog", &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)?, + done: load_stage_items(ctx, "5_done", &HashMap::new())?, + }) +} + +/// 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, merge_failure, review_hold, qa, retry_count, blocked) = match parse_front_matter(&contents) { + Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.qa.map(|m| m.as_str().to_string()), meta.retry_count, meta.blocked), + Err(e) => (None, Some(e.to_string()), None, None, None, None, None), + }; + let agent = agent_map.get(&story_id).cloned(); + stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, qa, retry_count, blocked }); + } + + 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_backlog", &HashMap::new()) +} + +pub fn validate_story_dirs( + root: &std::path::Path, +) -> Result, String> { + let mut results = Vec::new(); + + // Directories to validate: work/2_current/ + work/1_backlog/ + let dirs_to_validate: Vec = vec![ + root.join(".story_kit").join("work").join("2_current"), + root.join(".story_kit").join("work").join("1_backlog"), + ]; + + 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 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) +} + +// ── Shared utilities used by submodules ────────────────────────── + +/// Locate a work item file by searching all active pipeline stages. +/// +/// Searches in priority order: 2_current, 1_backlog, 3_qa, 4_merge, 5_done, 6_archived. +pub(super) 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"); + for stage in &["2_current", "1_backlog", "3_qa", "4_merge", "5_done", "6_archived"] { + let path = sk.join(stage).join(&filename); + if path.exists() { + return Ok(path); + } + } + Err(format!( + "Story '{story_id}' not found in any pipeline stage." + )) +} + +/// Replace the content of a named `## Section` in a story file. +/// +/// Finds the first occurrence of `## {section_name}` and replaces everything +/// until the next `##` heading (or end of file) with the provided text. +/// Returns an error if the section is not found. +pub(super) fn replace_section_content(content: &str, section_name: &str, new_text: &str) -> Result { + let lines: Vec<&str> = content.lines().collect(); + let heading = format!("## {section_name}"); + + let mut section_start: Option = None; + let mut section_end: Option = None; + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed == heading { + section_start = Some(i); + continue; + } + if section_start.is_some() && trimmed.starts_with("## ") { + section_end = Some(i); + break; + } + } + + let section_start = + section_start.ok_or_else(|| format!("Section '{heading}' not found in story file."))?; + + let mut new_lines: Vec = Vec::new(); + // Keep everything up to and including the section heading. + for line in lines.iter().take(section_start + 1) { + new_lines.push(line.to_string()); + } + // Blank line, new content, blank line. + new_lines.push(String::new()); + new_lines.push(new_text.to_string()); + new_lines.push(String::new()); + // Resume from the next section heading (or EOF). + let resume_from = section_end.unwrap_or(lines.len()); + for line in lines.iter().skip(resume_from) { + new_lines.push(line.to_string()); + } + + let mut new_str = new_lines.join("\n"); + if content.ends_with('\n') { + new_str.push('\n'); + } + Ok(new_str) +} + +/// Replace the `## Test Results` section in `contents` with `new_section`, +/// or append it if not present. +pub(super) fn replace_or_append_section(contents: &str, header: &str, new_section: &str) -> String { + let lines: Vec<&str> = contents.lines().collect(); + let header_trimmed = header.trim(); + + // Find the start of the existing section + let section_start = lines.iter().position(|l| l.trim() == header_trimmed); + + if let Some(start) = section_start { + // Find the next `##` heading after the section start (the end of this section) + let section_end = lines[start + 1..] + .iter() + .position(|l| { + let t = l.trim(); + t.starts_with("## ") && t != header_trimmed + }) + .map(|i| start + 1 + i) + .unwrap_or(lines.len()); + + let mut result = lines[..start].join("\n"); + if !result.is_empty() { + result.push('\n'); + } + result.push_str(new_section); + if section_end < lines.len() { + result.push('\n'); + result.push_str(&lines[section_end..].join("\n")); + } + if contents.ends_with('\n') { + result.push('\n'); + } + result + } else { + // Append at the end + let mut result = contents.trim_end_matches('\n').to_string(); + result.push_str("\n\n"); + result.push_str(new_section); + if !result.ends_with('\n') { + result.push('\n'); + } + result + } +} + +pub(super) 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). +pub(super) 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_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_pipeline_state_loads_all_stages() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_path_buf(); + + for (stage, id) in &[ + ("1_backlog", "10_story_upcoming"), + ("2_current", "20_story_current"), + ("3_qa", "30_story_qa"), + ("4_merge", "40_story_merge"), + ("5_done", "50_story_done"), + ] { + let dir = root.join(".story_kit").join("work").join(stage); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join(format!("{id}.md")), + format!("---\nname: {id}\n---\n"), + ) + .unwrap(); + } + + let ctx = crate::http::context::AppContext::new_test(root); + let state = load_pipeline_state(&ctx).unwrap(); + + assert_eq!(state.backlog.len(), 1); + assert_eq!(state.backlog[0].story_id, "10_story_upcoming"); + + assert_eq!(state.current.len(), 1); + assert_eq!(state.current[0].story_id, "20_story_current"); + + assert_eq!(state.qa.len(), 1); + assert_eq!(state.qa[0].story_id, "30_story_qa"); + + assert_eq!(state.merge.len(), 1); + assert_eq!(state.merge[0].story_id, "40_story_merge"); + + assert_eq!(state.done.len(), 1); + assert_eq!(state.done[0].story_id, "50_story_done"); + } + + #[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\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\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\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 backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write( + backlog.join("31_story_view_upcoming.md"), + "---\nname: View Upcoming\n---\n# Story\n", + ) + .unwrap(); + fs::write( + backlog.join("32_story_worktree.md"), + "---\nname: Worktree Orchestration\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 backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write(backlog.join(".gitkeep"), "").unwrap(); + fs::write( + backlog.join("31_story_example.md"), + "---\nname: A Story\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 backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&backlog).unwrap(); + fs::write( + current.join("28_story_todos.md"), + "---\nname: Show TODOs\n---\n# Story\n", + ) + .unwrap(); + fs::write( + backlog.join("36_story_front_matter.md"), + "---\nname: Enforce Front Matter\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")); + } + + #[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_backlog"); + 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 backlog = tmp.path().join(".story_kit/work/1_backlog"); + let current = tmp.path().join(".story_kit/work/2_current"); + let archived = tmp.path().join(".story_kit/work/5_done"); + fs::create_dir_all(&backlog).unwrap(); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&archived).unwrap(); + fs::write(backlog.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); + } + + // --- find_story_file tests --- + + #[test] + fn find_story_file_searches_current_then_backlog() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&backlog).unwrap(); + + // Only in backlog + fs::write(backlog.join("6_test.md"), "").unwrap(); + let found = find_story_file(tmp.path(), "6_test").unwrap(); + assert!(found.ends_with("1_backlog/6_test.md") || found.ends_with("1_backlog\\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")); + } + + // --- replace_or_append_section tests --- + + #[test] + fn replace_or_append_section_appends_when_absent() { + let contents = "---\nname: T\n---\n# Story\n"; + let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nfoo\n"); + assert!(new.contains("## Test Results")); + assert!(new.contains("foo")); + assert!(new.contains("# Story")); + } + + #[test] + fn replace_or_append_section_replaces_existing() { + let contents = "# Story\n\n## Test Results\n\nold content\n\n## Other\n\nother content\n"; + let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nnew content\n"); + assert!(new.contains("new content")); + assert!(!new.contains("old content")); + assert!(new.contains("## Other")); + } +} diff --git a/server/src/http/workflow/story_ops.rs b/server/src/http/workflow/story_ops.rs new file mode 100644 index 0000000..2147050 --- /dev/null +++ b/server/src/http/workflow/story_ops.rs @@ -0,0 +1,592 @@ +use crate::io::story_metadata::set_front_matter_field; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use super::{find_story_file, next_item_number, replace_section_content, slugify_name}; + +/// 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 backlog_dir = root.join(".story_kit").join("work").join("1_backlog"); + fs::create_dir_all(&backlog_dir) + .map_err(|e| format!("Failed to create backlog directory: {e}"))?; + + let filepath = backlog_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: \"{}\"\n", name.replace('"', "\\\""))); + 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) +} + +/// 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(()) +} + +/// Add a new acceptance criterion to a story file. +/// +/// Appends `- [ ] {criterion}` after the last existing criterion line in the +/// "## Acceptance Criteria" section, or directly after the section heading if +/// the section is empty. The filesystem watcher auto-commits the change. +pub fn add_criterion_to_file( + project_root: &Path, + story_id: &str, + criterion: &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 lines: Vec<&str> = contents.lines().collect(); + let mut in_ac_section = false; + let mut ac_section_start: Option = None; + let mut last_criterion_line: Option = None; + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed == "## Acceptance Criteria" { + in_ac_section = true; + ac_section_start = Some(i); + continue; + } + if in_ac_section { + if trimmed.starts_with("## ") { + break; + } + if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") { + last_criterion_line = Some(i); + } + } + } + + let insert_after = last_criterion_line + .or(ac_section_start) + .ok_or_else(|| { + format!("Story '{story_id}' has no '## Acceptance Criteria' section.") + })?; + + let mut new_lines: Vec = lines.iter().map(|s| s.to_string()).collect(); + new_lines.insert(insert_after + 1, format!("- [ ] {criterion}")); + + 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 user story text and/or description in a story file. +/// +/// At least one of `user_story` or `description` must be provided. +/// Replaces the content of the corresponding `##` section in place. +/// The filesystem watcher auto-commits the change. +pub fn update_story_in_file( + project_root: &Path, + story_id: &str, + user_story: Option<&str>, + description: Option<&str>, + front_matter: Option<&HashMap>, +) -> Result<(), String> { + let has_front_matter_updates = front_matter.map(|m| !m.is_empty()).unwrap_or(false); + if user_story.is_none() && description.is_none() && !has_front_matter_updates { + return Err( + "At least one of 'user_story', 'description', or 'front_matter' must be provided." + .to_string(), + ); + } + + let filepath = find_story_file(project_root, story_id)?; + let mut contents = fs::read_to_string(&filepath) + .map_err(|e| format!("Failed to read story file: {e}"))?; + + if let Some(fields) = front_matter { + for (key, value) in fields { + let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', "")); + contents = set_front_matter_field(&contents, key, &yaml_value); + } + } + + if let Some(us) = user_story { + contents = replace_section_content(&contents, "User Story", us)?; + } + if let Some(desc) = description { + contents = replace_section_content(&contents, "Description", desc)?; + } + + fs::write(&filepath, &contents) + .map_err(|e| format!("Failed to write story file: {e}"))?; + + // Watcher handles the git commit asynchronously. + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::io::story_metadata::parse_front_matter; + + 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\n---\n\n## Acceptance Criteria\n\n".to_string(); + for i in 0..n { + s.push_str(&format!("- [ ] Criterion {i}\n")); + } + s + } + + // --- create_story integration tests --- + + #[test] + fn create_story_writes_correct_content() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write(backlog.join("36_story_existing.md"), "").unwrap(); + + let number = super::super::next_item_number(tmp.path()).unwrap(); + assert_eq!(number, 37); + + let slug = super::super::slugify_name("My New Feature"); + assert_eq!(slug, "my_new_feature"); + + let filename = format!("{number}_{slug}.md"); + let filepath = backlog.join(&filename); + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str("name: \"My New Feature\"\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\"\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_with_colon_in_name_produces_valid_yaml() { + let tmp = tempfile::tempdir().unwrap(); + let name = "Server-owned agent completion: remove report_completion dependency"; + let result = create_story_file(tmp.path(), name, None, None, false); + assert!(result.is_ok(), "create_story_file failed: {result:?}"); + + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + let story_id = result.unwrap(); + let filename = format!("{story_id}.md"); + let contents = fs::read_to_string(backlog.join(&filename)).unwrap(); + + let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); + assert_eq!(meta.name.as_deref(), Some(name)); + } + + #[test] + fn create_story_rejects_duplicate() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + + let filepath = backlog.join("1_story_my_feature.md"); + fs::write(&filepath, "existing").unwrap(); + + // Simulate the check + assert!(filepath.exists()); + } + + // ── check_criterion_in_file tests ───────────────────────────────────────── + + #[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")); + } + + // ── add_criterion_to_file tests ─────────────────────────────────────────── + + fn story_with_ac_section(criteria: &[&str]) -> String { + let mut s = "---\nname: Test\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n".to_string(); + for c in criteria { + s.push_str(&format!("- [ ] {c}\n")); + } + s.push_str("\n## Out of Scope\n\n- N/A\n"); + s + } + + #[test] + fn add_criterion_appends_after_last_criterion() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("10_test.md"); + fs::write(&filepath, story_with_ac_section(&["First", "Second"])).unwrap(); + + add_criterion_to_file(tmp.path(), "10_test", "Third").unwrap(); + + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("- [ ] First\n")); + assert!(contents.contains("- [ ] Second\n")); + assert!(contents.contains("- [ ] Third\n")); + // Third should come after Second + let pos_second = contents.find("- [ ] Second").unwrap(); + let pos_third = contents.find("- [ ] Third").unwrap(); + assert!(pos_third > pos_second, "Third should appear after Second"); + } + + #[test] + fn add_criterion_to_empty_section() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("11_test.md"); + let content = "---\nname: Test\n---\n\n## Acceptance Criteria\n\n## Out of Scope\n\n- N/A\n"; + fs::write(&filepath, content).unwrap(); + + add_criterion_to_file(tmp.path(), "11_test", "New AC").unwrap(); + + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("- [ ] New AC\n"), "criterion should be present"); + } + + #[test] + fn add_criterion_missing_section_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("12_test.md"); + fs::write(&filepath, "---\nname: Test\n---\n\nNo AC section here.\n").unwrap(); + + let result = add_criterion_to_file(tmp.path(), "12_test", "X"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Acceptance Criteria")); + } + + // ── update_story_in_file tests ───────────────────────────────────────────── + + #[test] + fn update_story_replaces_user_story_section() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("20_test.md"); + let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n"; + fs::write(&filepath, content).unwrap(); + + update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None, None).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("New user story text"), "new text should be present"); + assert!(!result.contains("Old text"), "old text should be replaced"); + assert!(result.contains("## Acceptance Criteria"), "other sections preserved"); + } + + #[test] + fn update_story_replaces_description_section() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("21_test.md"); + let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n"; + fs::write(&filepath, content).unwrap(); + + update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("New description"), "new description present"); + assert!(!result.contains("Old description"), "old description replaced"); + } + + #[test] + fn update_story_no_args_returns_error() { + 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("22_test.md"), "---\nname: T\n---\n").unwrap(); + + let result = update_story_in_file(tmp.path(), "22_test", None, None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("At least one")); + } + + #[test] + fn update_story_missing_section_returns_error() { + 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("23_test.md"), + "---\nname: T\n---\n\nNo sections here.\n", + ) + .unwrap(); + + let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("User Story")); + } + + #[test] + fn update_story_sets_agent_front_matter_field() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("24_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("agent".to_string(), "dev".to_string()); + update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("agent: \"dev\""), "agent field should be set"); + assert!(result.contains("name: T"), "name field preserved"); + } + + #[test] + fn update_story_sets_arbitrary_front_matter_fields() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("25_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("qa".to_string(), "human".to_string()); + fields.insert("priority".to_string(), "high".to_string()); + update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("qa: \"human\""), "qa field should be set"); + assert!(result.contains("priority: \"high\""), "priority field should be set"); + assert!(result.contains("name: T"), "name field preserved"); + } + + #[test] + fn update_story_front_matter_only_no_section_required() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + // File without a User Story section — front matter update should succeed + let filepath = current.join("26_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\nNo sections here.\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("agent".to_string(), "dev".to_string()); + let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields)); + assert!(result.is_ok(), "front-matter-only update should not require body sections"); + + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains("agent: \"dev\"")); + } +} diff --git a/server/src/http/workflow/test_results.rs b/server/src/http/workflow/test_results.rs new file mode 100644 index 0000000..ad670fb --- /dev/null +++ b/server/src/http/workflow/test_results.rs @@ -0,0 +1,261 @@ +use crate::io::story_metadata::write_coverage_baseline; +use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; +use std::fs; +use std::path::Path; + +use super::{find_story_file, replace_or_append_section}; + +const TEST_RESULTS_MARKER: &str = "\n\n")); + + // Unit tests + let (unit_pass, unit_fail) = count_pass_fail(&results.unit); + s.push_str(&format!( + "### Unit Tests ({unit_pass} passed, {unit_fail} failed)\n\n" + )); + if results.unit.is_empty() { + s.push_str("*No unit tests recorded.*\n"); + } else { + for t in &results.unit { + s.push_str(&format_test_line(t)); + } + } + s.push('\n'); + + // Integration tests + let (int_pass, int_fail) = count_pass_fail(&results.integration); + s.push_str(&format!( + "### Integration Tests ({int_pass} passed, {int_fail} failed)\n\n" + )); + if results.integration.is_empty() { + s.push_str("*No integration tests recorded.*\n"); + } else { + for t in &results.integration { + s.push_str(&format_test_line(t)); + } + } + + s +} + +fn count_pass_fail(tests: &[TestCaseResult]) -> (usize, usize) { + let pass = tests.iter().filter(|t| t.status == TestStatus::Pass).count(); + (pass, tests.len() - pass) +} + +fn format_test_line(t: &TestCaseResult) -> String { + let icon = if t.status == TestStatus::Pass { "✅" } else { "❌" }; + match &t.details { + Some(d) if !d.is_empty() => format!("- {icon} {} — {d}\n", t.name), + _ => format!("- {icon} {}\n", t.name), + } +} + +/// Parse `StoryTestResults` from the JSON embedded in the `## Test Results` section. +fn parse_test_results_from_contents(contents: &str) -> Option { + for line in contents.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix(TEST_RESULTS_MARKER) { + // rest looks like: ` {...} -->` + if let Some(json_end) = rest.rfind("-->") { + let json_str = rest[..json_end].trim(); + if let Ok(results) = serde_json::from_str::(json_str) { + return Some(results); + } + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; + + fn make_results() -> StoryTestResults { + StoryTestResults { + unit: vec![ + TestCaseResult { name: "unit-pass".to_string(), status: TestStatus::Pass, details: None }, + TestCaseResult { name: "unit-fail".to_string(), status: TestStatus::Fail, details: Some("assertion failed".to_string()) }, + ], + integration: vec![ + TestCaseResult { name: "int-pass".to_string(), status: TestStatus::Pass, details: None }, + ], + } + } + + #[test] + fn write_and_read_test_results_roundtrip() { + 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("1_story_test.md"), "---\nname: Test\n---\n# Story\n").unwrap(); + + let results = make_results(); + write_test_results_to_story_file(tmp.path(), "1_story_test", &results).unwrap(); + + let read_back = read_test_results_from_story_file(tmp.path(), "1_story_test") + .expect("should read back results"); + assert_eq!(read_back.unit.len(), 2); + assert_eq!(read_back.integration.len(), 1); + assert_eq!(read_back.unit[0].name, "unit-pass"); + assert_eq!(read_back.unit[1].status, TestStatus::Fail); + assert_eq!(read_back.unit[1].details.as_deref(), Some("assertion failed")); + } + + #[test] + fn write_test_results_creates_readable_section() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let story_path = current.join("2_story_check.md"); + fs::write(&story_path, "---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n").unwrap(); + + let results = make_results(); + write_test_results_to_story_file(tmp.path(), "2_story_check", &results).unwrap(); + + let contents = fs::read_to_string(&story_path).unwrap(); + assert!(contents.contains("## Test Results")); + assert!(contents.contains("✅ unit-pass")); + assert!(contents.contains("❌ unit-fail")); + assert!(contents.contains("assertion failed")); + assert!(contents.contains("story-kit-test-results:")); + // Original content still present + assert!(contents.contains("## Acceptance Criteria")); + } + + #[test] + fn write_test_results_overwrites_existing_section() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let story_path = current.join("3_story_overwrite.md"); + fs::write( + &story_path, + "---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n", + ) + .unwrap(); + + let results = make_results(); + write_test_results_to_story_file(tmp.path(), "3_story_overwrite", &results).unwrap(); + + let contents = fs::read_to_string(&story_path).unwrap(); + assert!(contents.contains("✅ unit-pass")); + // Should have only one ## Test Results header + let count = contents.matches("## Test Results").count(); + assert_eq!(count, 1, "should have exactly one ## Test Results section"); + } + + #[test] + fn read_test_results_returns_none_when_no_section() { + 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("4_story_empty.md"), "---\nname: Empty\n---\n# Story\n").unwrap(); + + let result = read_test_results_from_story_file(tmp.path(), "4_story_empty"); + assert!(result.is_none()); + } + + #[test] + fn read_test_results_returns_none_for_unknown_story() { + let tmp = tempfile::tempdir().unwrap(); + let result = read_test_results_from_story_file(tmp.path(), "99_story_unknown"); + assert!(result.is_none()); + } + + #[test] + fn write_test_results_finds_story_in_any_stage() { + let tmp = tempfile::tempdir().unwrap(); + let qa_dir = tmp.path().join(".story_kit/work/3_qa"); + fs::create_dir_all(&qa_dir).unwrap(); + fs::write(qa_dir.join("5_story_qa.md"), "---\nname: QA Story\n---\n# Story\n").unwrap(); + + let results = StoryTestResults { + unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None }], + integration: vec![], + }; + write_test_results_to_story_file(tmp.path(), "5_story_qa", &results).unwrap(); + + let read_back = read_test_results_from_story_file(tmp.path(), "5_story_qa").unwrap(); + assert_eq!(read_back.unit.len(), 1); + } + + #[test] + fn write_coverage_baseline_to_story_file_updates_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("6_story_cov.md"), "---\nname: Cov Story\n---\n# Story\n").unwrap(); + + write_coverage_baseline_to_story_file(tmp.path(), "6_story_cov", 75.4).unwrap(); + + let contents = fs::read_to_string(current.join("6_story_cov.md")).unwrap(); + assert!(contents.contains("coverage_baseline: 75.4%"), "got: {contents}"); + } + + #[test] + fn write_coverage_baseline_to_story_file_silent_on_missing_story() { + let tmp = tempfile::tempdir().unwrap(); + // Story doesn't exist — should succeed silently + let result = write_coverage_baseline_to_story_file(tmp.path(), "99_story_missing", 50.0); + assert!(result.is_ok()); + } +}