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(".storkit").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(()) } /// Encode a string value as a YAML scalar. /// /// Booleans (`true`/`false`) and integers are written as native YAML types (unquoted). /// Everything else is written as a quoted string to avoid ambiguity. fn yaml_encode_scalar(value: &str) -> String { match value { "true" | "false" => value.to_string(), s if s.parse::().is_ok() => s.to_string(), s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")), } } /// 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 = yaml_encode_scalar(value); 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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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(".storkit/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\"")); } #[test] fn update_story_bool_front_matter_written_unquoted() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("27_test.md"); fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap(); let mut fields = HashMap::new(); fields.insert("blocked".to_string(), "false".to_string()); update_story_in_file(tmp.path(), "27_test", None, None, Some(&fields)).unwrap(); let result = fs::read_to_string(&filepath).unwrap(); assert!(result.contains("blocked: false"), "bool should be unquoted: {result}"); assert!(!result.contains("blocked: \"false\""), "bool must not be quoted: {result}"); } #[test] fn update_story_integer_front_matter_written_unquoted() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("28_test.md"); fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap(); let mut fields = HashMap::new(); fields.insert("retry_count".to_string(), "0".to_string()); update_story_in_file(tmp.path(), "28_test", None, None, Some(&fields)).unwrap(); let result = fs::read_to_string(&filepath).unwrap(); assert!(result.contains("retry_count: 0"), "integer should be unquoted: {result}"); assert!(!result.contains("retry_count: \"0\""), "integer must not be quoted: {result}"); } #[test] fn update_story_bool_front_matter_parseable_after_write() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".storkit/work/2_current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("29_test.md"); fs::write(&filepath, "---\nname: My Story\n---\n\nNo sections.\n").unwrap(); let mut fields = HashMap::new(); fields.insert("blocked".to_string(), "false".to_string()); update_story_in_file(tmp.path(), "29_test", None, None, Some(&fields)).unwrap(); let contents = fs::read_to_string(&filepath).unwrap(); let meta = parse_front_matter(&contents).expect("front matter should parse"); assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing bool field"); } }