From 61ae30873f06d34ecfa92f23c67afb0d67bab2da Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 10 Apr 2026 10:25:07 +0000 Subject: [PATCH] huskies: merge 516_story_update_story_description_should_create_the_description_section_if_it_doesn_t_exist_instead_of_erroring --- server/src/http/workflow/mod.rs | 47 +++++++++++++++++++ server/src/http/workflow/story_ops.rs | 65 +++++++++++++++++++++++---- 2 files changed, 104 insertions(+), 8 deletions(-) diff --git a/server/src/http/workflow/mod.rs b/server/src/http/workflow/mod.rs index 083d03fb..1d8d97fc 100644 --- a/server/src/http/workflow/mod.rs +++ b/server/src/http/workflow/mod.rs @@ -517,6 +517,53 @@ pub(super) fn replace_section_content(content: &str, section_name: &str, new_tex Ok(new_str) } +/// Insert a new `## {section_name}` section into `content`. +/// +/// The new section is placed immediately before the first occurrence of +/// `## {before_section}`. If `before_section` is `None` or not found in the +/// document, the section is appended at the end. +pub(super) fn create_section_content( + content: &str, + section_name: &str, + new_text: &str, + before_section: Option<&str>, +) -> String { + let lines: Vec<&str> = content.lines().collect(); + + let insert_at = before_section + .and_then(|before| { + let heading = format!("## {before}"); + lines.iter().position(|l| l.trim() == heading) + }) + .unwrap_or(lines.len()); + + let mut new_lines: Vec = Vec::new(); + + for line in lines.iter().take(insert_at) { + new_lines.push(line.to_string()); + } + + // Ensure a blank line before the new heading. + if new_lines.last().map(|l| !l.is_empty()).unwrap_or(false) { + new_lines.push(String::new()); + } + + new_lines.push(format!("## {section_name}")); + new_lines.push(String::new()); + new_lines.push(new_text.to_string()); + new_lines.push(String::new()); + + for line in lines.iter().skip(insert_at) { + new_lines.push(line.to_string()); + } + + let mut new_str = new_lines.join("\n"); + if content.ends_with('\n') { + new_str.push('\n'); + } + 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 { diff --git a/server/src/http/workflow/story_ops.rs b/server/src/http/workflow/story_ops.rs index e99769d7..ceb38ade 100644 --- a/server/src/http/workflow/story_ops.rs +++ b/server/src/http/workflow/story_ops.rs @@ -3,7 +3,7 @@ use serde_json::Value; use std::collections::HashMap; use std::path::Path; -use super::{next_item_number, read_story_content, replace_section_content, slugify_name, story_stage, write_story_content_with_fs}; +use super::{create_section_content, next_item_number, read_story_content, replace_section_content, slugify_name, story_stage, write_story_content_with_fs}; /// Shared create-story logic used by both the OpenApi and MCP handlers. /// @@ -249,10 +249,16 @@ pub fn update_story_in_file( } if let Some(us) = user_story { - contents = replace_section_content(&contents, "User Story", us)?; + contents = match replace_section_content(&contents, "User Story", us) { + Ok(updated) => updated, + Err(_) => create_section_content(&contents, "User Story", us, Some("Acceptance Criteria")), + }; } if let Some(desc) = description { - contents = replace_section_content(&contents, "Description", desc)?; + contents = match replace_section_content(&contents, "Description", desc) { + Ok(updated) => updated, + Err(_) => create_section_content(&contents, "Description", desc, Some("Acceptance Criteria")), + }; } // Write back to content store. @@ -505,13 +511,56 @@ mod tests { } #[test] - fn update_story_missing_section_returns_error() { + fn update_story_creates_user_story_section_if_missing() { let tmp = tempfile::tempdir().unwrap(); - setup_story_in_fs(tmp.path(), "23_test", "---\nname: T\n---\n\nNo sections here.\n"); + // Story with no ## User Story section but has ## Acceptance Criteria. + let content = "---\nname: T\n---\n\n## Acceptance Criteria\n\n- [ ] AC\n"; + setup_story_in_fs(tmp.path(), "23_test", content); - 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")); + let result = update_story_in_file(tmp.path(), "23_test", Some("New user story"), None, None); + assert!(result.is_ok(), "should succeed when section is missing: {result:?}"); + + let updated = read_story_content(tmp.path(), "23_test").unwrap(); + assert!(updated.contains("## User Story"), "section should be created"); + assert!(updated.contains("New user story"), "text should be present"); + // Section should appear before Acceptance Criteria. + let pos_us = updated.find("## User Story").unwrap(); + let pos_ac = updated.find("## Acceptance Criteria").unwrap(); + assert!(pos_us < pos_ac, "User Story should be before Acceptance Criteria"); + } + + #[test] + fn update_story_creates_description_section_if_missing() { + let tmp = tempfile::tempdir().unwrap(); + // Story with no ## Description section but has ## Acceptance Criteria. + let content = "---\nname: T\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n- [ ] AC\n"; + setup_story_in_fs(tmp.path(), "32_test", content); + + let result = update_story_in_file(tmp.path(), "32_test", None, Some("New description text"), None); + assert!(result.is_ok(), "should succeed when section is missing: {result:?}"); + + let updated = read_story_content(tmp.path(), "32_test").unwrap(); + assert!(updated.contains("## Description"), "section should be created"); + assert!(updated.contains("New description text"), "text should be present"); + // Section should appear before Acceptance Criteria. + let pos_desc = updated.find("## Description").unwrap(); + let pos_ac = updated.find("## Acceptance Criteria").unwrap(); + assert!(pos_desc < pos_ac, "Description should be before Acceptance Criteria"); + } + + #[test] + fn update_story_creates_description_section_no_ac_section() { + let tmp = tempfile::tempdir().unwrap(); + // Story with no ## Description and no ## Acceptance Criteria. + let content = "---\nname: T\n---\n\nSome content here.\n"; + setup_story_in_fs(tmp.path(), "33_test", content); + + let result = update_story_in_file(tmp.path(), "33_test", None, Some("Appended description"), None); + assert!(result.is_ok(), "should succeed even with no Acceptance Criteria: {result:?}"); + + let updated = read_story_content(tmp.path(), "33_test").unwrap(); + assert!(updated.contains("## Description"), "section should be created"); + assert!(updated.contains("Appended description"), "text should be present"); } #[test]