huskies: merge 798

This commit is contained in:
dave
2026-04-28 16:16:47 +00:00
parent 1e40215c3e
commit a65cd86c8f
4 changed files with 910 additions and 889 deletions
+315
View File
@@ -0,0 +1,315 @@
//! 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<String, String> {
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<String> {
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<String, String> {
let lines: Vec<&str> = content.lines().collect();
let heading = format!("## {section_name}");
let mut section_start: Option<usize> = None;
let mut section_end: Option<usize> = 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<String> = 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<String> = 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<u32, String> {
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"));
}
}