//! Shared utilities for workflow operations — content I/O, section manipulation, and slugification. use std::path::Path; /// Read story content from the database content store. /// /// Returns the story content or an error if not found. pub(crate) fn read_story_content(_project_root: &Path, story_id: &str) -> Result { crate::db::read_content(story_id) .ok_or_else(|| format!("Story '{story_id}' not found in any pipeline stage.")) } /// Write story content to the DB content store and CRDT. pub(crate) fn write_story_content( _project_root: &Path, story_id: &str, stage: &str, content: &str, ) { crate::db::write_item_with_content(story_id, stage, content); } /// Determine what stage a story is in (from CRDT). pub(crate) fn story_stage(story_id: &str) -> Option { crate::pipeline_state::read_typed(story_id) .ok() .flatten() .map(|item| item.stage.dir_name().to_string()) } /// 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(crate) 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) } /// 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(crate) 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 `## {header}` section in `contents` with `new_section`, /// or append it if not present. pub(crate) 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 } } /// Convert a human-readable name to a lowercase snake_case slug. pub(crate) 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 } /// Get the next available item number from the database/CRDT. pub(crate) fn next_item_number(_root: &std::path::Path) -> Result { Ok(crate::db::next_item_number()) } #[cfg(test)] mod tests { use super::*; // --- 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_returns_at_least_1() { let tmp = tempfile::tempdir().unwrap(); // May be higher due to shared global CRDT state in tests. assert!(next_item_number(tmp.path()).unwrap() >= 1); } #[test] fn next_item_number_increments_beyond_existing() { crate::db::ensure_content_store(); crate::db::write_item_with_content("9877_story_foo", "1_backlog", "---\nname: Foo\n---\n"); let tmp = tempfile::tempdir().unwrap(); assert!(next_item_number(tmp.path()).unwrap() >= 9878); } // --- read_story_content tests --- #[test] fn read_story_content_from_content_store() { crate::db::ensure_content_store(); let content = "---\nname: Test\n---\n# Story\n"; crate::db::write_content("9878_story_read_test", content); let tmp = tempfile::tempdir().unwrap(); let result = read_story_content(tmp.path(), "9878_story_read_test").unwrap(); assert_eq!(result, content); } #[test] fn read_story_content_not_found_returns_error() { let tmp = tempfile::tempdir().unwrap(); let result = read_story_content(tmp.path(), "99999_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")); } }