huskies: merge 798
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user