huskies: merge 942

This commit is contained in:
dave
2026-05-13 05:16:11 +00:00
parent 7ca5339450
commit 0a825b9f27
11 changed files with 416 additions and 258 deletions
+23 -1
View File
@@ -71,7 +71,7 @@ pub(crate) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result<String,
steps_to_reproduce, steps_to_reproduce,
actual_result, actual_result,
expected_result, expected_result,
Some(&acceptance_criteria), &acceptance_criteria,
depends_on.as_deref(), depends_on.as_deref(),
)?; )?;
@@ -218,6 +218,28 @@ mod tests {
assert!(result.unwrap_err().contains("name")); assert!(result.unwrap_err().contains("name"));
} }
#[test]
fn tool_create_bug_rejects_whitespace_only_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let err = tool_create_bug(
&json!({
"name": " ",
"description": "d",
"steps_to_reproduce": "s",
"actual_result": "a",
"expected_result": "e",
"acceptance_criteria": ["Bug fixed"]
}),
&ctx,
)
.unwrap_err();
assert!(
err.contains("empty") || err.contains("whitespace"),
"error should mention empty/whitespace, got: {err}"
);
}
#[test] #[test]
fn tool_create_bug_missing_description() { fn tool_create_bug_missing_description() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+16 -1
View File
@@ -53,7 +53,7 @@ pub(crate) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result<Str
&root, &root,
name, name,
description, description,
Some(&acceptance_criteria), &acceptance_criteria,
depends_on.as_deref(), depends_on.as_deref(),
)?; )?;
@@ -78,6 +78,21 @@ mod tests {
use crate::http::test_helpers::test_ctx; use crate::http::test_helpers::test_ctx;
use serde_json::json; use serde_json::json;
#[test]
fn tool_create_refactor_rejects_whitespace_only_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let err = tool_create_refactor(
&json!({"name": " ", "acceptance_criteria": ["Code is clean"]}),
&ctx,
)
.unwrap_err();
assert!(
err.contains("empty") || err.contains("whitespace"),
"error should mention empty/whitespace, got: {err}"
);
}
#[test] #[test]
fn tool_create_refactor_rejects_missing_acceptance_criteria() { fn tool_create_refactor_rejects_missing_acceptance_criteria() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+15
View File
@@ -103,6 +103,21 @@ mod tests {
assert!(result.unwrap_err().contains("alphanumeric")); assert!(result.unwrap_err().contains("alphanumeric"));
} }
#[test]
fn tool_create_spike_rejects_whitespace_only_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let err = tool_create_spike(
&json!({"name": " ", "acceptance_criteria": ["Findings documented"]}),
&ctx,
)
.unwrap_err();
assert!(
err.contains("empty") || err.contains("whitespace"),
"error should mention empty/whitespace, got: {err}"
);
}
#[test] #[test]
fn tool_create_spike_creates_file() { fn tool_create_spike_creates_file() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
@@ -42,7 +42,7 @@ pub(crate) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String
name, name,
user_story, user_story,
description, description,
Some(&acceptance_criteria), &acceptance_criteria,
depends_on.as_deref(), depends_on.as_deref(),
commit, commit,
)?; )?;
@@ -138,6 +138,21 @@ mod tests {
assert!(result.unwrap_err().contains("alphanumeric")); assert!(result.unwrap_err().contains("alphanumeric"));
} }
#[test]
fn tool_create_story_rejects_whitespace_only_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let err = tool_create_story(
&json!({"name": " ", "acceptance_criteria": ["AC1"]}),
&ctx,
)
.unwrap_err();
assert!(
err.contains("empty") || err.contains("whitespace"),
"error should mention empty/whitespace, got: {err}"
);
}
#[test] #[test]
fn tool_create_story_rejects_missing_acceptance_criteria() { fn tool_create_story_rejects_missing_acceptance_criteria() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
+29 -34
View File
@@ -2,11 +2,12 @@
use std::path::Path; use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content}; use super::super::create_item_in_backlog;
/// Create a bug file and store it in the database. /// Create a bug file and store it in the database.
/// ///
/// Also writes to the filesystem for backwards compatibility during migration. /// Routes through `create_item_in_backlog`, the single internal creation path.
/// `acceptance_criteria` must be non-empty; an empty slice returns an error.
/// Returns the bug_id (e.g. `"4"`). /// Returns the bug_id (e.g. `"4"`).
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn create_bug_file( pub fn create_bug_file(
@@ -16,59 +17,53 @@ pub fn create_bug_file(
steps_to_reproduce: &str, steps_to_reproduce: &str,
actual_result: &str, actual_result: &str,
expected_result: &str, expected_result: &str,
acceptance_criteria: Option<&[String]>, acceptance_criteria: &[String],
depends_on: Option<&[u32]>, depends_on: Option<&[u32]>,
) -> Result<String, String> { ) -> Result<String, String> {
let bug_number = next_item_number(root)?; let name_owned = name.to_string();
let slug = slugify_name(name); let description_owned = description.to_string();
let steps_owned = steps_to_reproduce.to_string();
if slug.is_empty() { let actual_owned = actual_result.to_string();
return Err("Name must contain at least one alphanumeric character.".to_string()); let expected_owned = expected_result.to_string();
} let depends_on_owned: Option<Vec<u32>> = depends_on.map(<[u32]>::to_vec);
let acs_owned: Vec<String> = acceptance_criteria.to_vec();
let bug_id = format!("{bug_number}");
create_item_in_backlog(
root,
"bug",
name,
acceptance_criteria,
depends_on,
move |bug_number| {
let mut content = String::new(); let mut content = String::new();
content.push_str("---\n"); content.push_str("---\n");
content.push_str("type: bug\n"); content.push_str("type: bug\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); content.push_str(&format!("name: \"{}\"\n", name_owned.replace('"', "\\\"")));
if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { if let Some(ref deps) = depends_on_owned.filter(|d| !d.is_empty()) {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect(); let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); content.push_str(&format!("depends_on: [{}]\n", nums.join(", ")));
} }
content.push_str("---\n\n"); content.push_str("---\n\n");
content.push_str(&format!("# Bug {bug_number}: {name}\n\n")); content.push_str(&format!("# Bug {bug_number}: {name_owned}\n\n"));
content.push_str("## Description\n\n"); content.push_str("## Description\n\n");
content.push_str(description); content.push_str(&description_owned);
content.push_str("\n\n"); content.push_str("\n\n");
content.push_str("## How to Reproduce\n\n"); content.push_str("## How to Reproduce\n\n");
content.push_str(steps_to_reproduce); content.push_str(&steps_owned);
content.push_str("\n\n"); content.push_str("\n\n");
content.push_str("## Actual Result\n\n"); content.push_str("## Actual Result\n\n");
content.push_str(actual_result); content.push_str(&actual_owned);
content.push_str("\n\n"); content.push_str("\n\n");
content.push_str("## Expected Result\n\n"); content.push_str("## Expected Result\n\n");
content.push_str(expected_result); content.push_str(&expected_owned);
content.push_str("\n\n"); content.push_str("\n\n");
content.push_str("## Acceptance Criteria\n\n"); content.push_str("## Acceptance Criteria\n\n");
if let Some(criteria) = acceptance_criteria { for criterion in &acs_owned {
for criterion in criteria {
content.push_str(&format!("- [ ] {criterion}\n")); content.push_str(&format!("- [ ] {criterion}\n"));
} }
} else { content
content.push_str("- [ ] Bug is fixed and verified\n"); },
} )
// Write to database content store and CRDT.
write_story_content(root, &bug_id, "1_backlog", &content, Some(name));
// Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&bug_id, depends_on.unwrap_or(&[]));
// Story 933: typed CRDT register for item_type.
crate::crdt_state::set_item_type(&bug_id, Some("bug"));
Ok(bug_id)
} }
/// Returns true if the item stem is a bug item. /// Returns true if the item stem is a bug item.
+23 -30
View File
@@ -2,41 +2,46 @@
use std::path::Path; use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content}; use super::super::create_item_in_backlog;
/// Create a refactor work item and store it in the database. /// Create a refactor work item and store it in the database.
/// ///
/// Routes through `create_item_in_backlog`, the single internal creation path.
/// `acceptance_criteria` must be non-empty; an empty slice returns an error.
/// Returns the refactor_id (e.g. `"5"`). /// Returns the refactor_id (e.g. `"5"`).
pub fn create_refactor_file( pub fn create_refactor_file(
root: &Path, root: &Path,
name: &str, name: &str,
description: Option<&str>, description: Option<&str>,
acceptance_criteria: Option<&[String]>, acceptance_criteria: &[String],
depends_on: Option<&[u32]>, depends_on: Option<&[u32]>,
) -> Result<String, String> { ) -> Result<String, String> {
let refactor_number = next_item_number(root)?; let name_owned = name.to_string();
let slug = slugify_name(name); let description_owned = description.map(str::to_string);
let depends_on_owned: Option<Vec<u32>> = depends_on.map(<[u32]>::to_vec);
if slug.is_empty() { let acs_owned: Vec<String> = acceptance_criteria.to_vec();
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let refactor_id = format!("{refactor_number}");
create_item_in_backlog(
root,
"refactor",
name,
acceptance_criteria,
depends_on,
move |refactor_number| {
let mut content = String::new(); let mut content = String::new();
content.push_str("---\n"); content.push_str("---\n");
content.push_str("type: refactor\n"); content.push_str("type: refactor\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); content.push_str(&format!("name: \"{}\"\n", name_owned.replace('"', "\\\"")));
if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { if let Some(ref deps) = depends_on_owned.filter(|d| !d.is_empty()) {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect(); let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); content.push_str(&format!("depends_on: [{}]\n", nums.join(", ")));
} }
content.push_str("---\n\n"); content.push_str("---\n\n");
content.push_str(&format!("# Refactor {refactor_number}: {name}\n\n")); content.push_str(&format!("# Refactor {refactor_number}: {name_owned}\n\n"));
content.push_str("## Current State\n\n"); content.push_str("## Current State\n\n");
content.push_str("- TBD\n\n"); content.push_str("- TBD\n\n");
content.push_str("## Desired State\n\n"); content.push_str("## Desired State\n\n");
if let Some(desc) = description { if let Some(ref desc) = description_owned {
content.push_str(desc); content.push_str(desc);
content.push('\n'); content.push('\n');
} else { } else {
@@ -44,27 +49,15 @@ pub fn create_refactor_file(
} }
content.push('\n'); content.push('\n');
content.push_str("## Acceptance Criteria\n\n"); content.push_str("## Acceptance Criteria\n\n");
if let Some(criteria) = acceptance_criteria { for criterion in &acs_owned {
for criterion in criteria {
content.push_str(&format!("- [ ] {criterion}\n")); content.push_str(&format!("- [ ] {criterion}\n"));
} }
} else {
content.push_str("- [ ] Refactoring complete and all tests pass\n");
}
content.push('\n'); content.push('\n');
content.push_str("## Out of Scope\n\n"); content.push_str("## Out of Scope\n\n");
content.push_str("- TBD\n"); content.push_str("- TBD\n");
content
// Write to database content store and CRDT. },
write_story_content(root, &refactor_id, "1_backlog", &content, Some(name)); )
// Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&refactor_id, depends_on.unwrap_or(&[]));
// Story 933: typed CRDT register for item_type.
crate::crdt_state::set_item_type(&refactor_id, Some("refactor"));
Ok(refactor_id)
} }
/// Returns true if the item stem is a refactor item. /// Returns true if the item stem is a refactor item.
+22 -29
View File
@@ -2,10 +2,12 @@
use std::path::Path; use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content}; use super::super::create_item_in_backlog;
/// Create a spike file and store it in the database. /// Create a spike file and store it in the database.
/// ///
/// Routes through `create_item_in_backlog`, the single internal creation path.
/// `acceptance_criteria` must be non-empty; an empty slice returns an error.
/// Returns the spike_id (e.g. `"4"`). /// Returns the spike_id (e.g. `"4"`).
pub fn create_spike_file( pub fn create_spike_file(
root: &Path, root: &Path,
@@ -14,27 +16,30 @@ pub fn create_spike_file(
acceptance_criteria: &[String], acceptance_criteria: &[String],
depends_on: Option<&[u32]>, depends_on: Option<&[u32]>,
) -> Result<String, String> { ) -> Result<String, String> {
let spike_number = next_item_number(root)?; let name_owned = name.to_string();
let slug = slugify_name(name); let description_owned = description.map(str::to_string);
let depends_on_owned: Option<Vec<u32>> = depends_on.map(<[u32]>::to_vec);
if slug.is_empty() { let acs_owned: Vec<String> = acceptance_criteria.to_vec();
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let spike_id = format!("{spike_number}");
create_item_in_backlog(
root,
"spike",
name,
acceptance_criteria,
depends_on,
move |spike_number| {
let mut content = String::new(); let mut content = String::new();
content.push_str("---\n"); content.push_str("---\n");
content.push_str("type: spike\n"); content.push_str("type: spike\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); content.push_str(&format!("name: \"{}\"\n", name_owned.replace('"', "\\\"")));
if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { if let Some(ref deps) = depends_on_owned.filter(|d| !d.is_empty()) {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect(); let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); content.push_str(&format!("depends_on: [{}]\n", nums.join(", ")));
} }
content.push_str("---\n\n"); content.push_str("---\n\n");
content.push_str(&format!("# Spike {spike_number}: {name}\n\n")); content.push_str(&format!("# Spike {spike_number}: {name_owned}\n\n"));
content.push_str("## Question\n\n"); content.push_str("## Question\n\n");
if let Some(desc) = description { if let Some(ref desc) = description_owned {
content.push_str(desc); content.push_str(desc);
content.push('\n'); content.push('\n');
} else { } else {
@@ -52,22 +57,10 @@ pub fn create_spike_file(
content.push_str("## Recommendation\n\n"); content.push_str("## Recommendation\n\n");
content.push_str("- TBD\n\n"); content.push_str("- TBD\n\n");
content.push_str("## Acceptance Criteria\n\n"); content.push_str("## Acceptance Criteria\n\n");
if acceptance_criteria.is_empty() { for criterion in &acs_owned {
content.push_str("- [ ] TBD\n");
} else {
for criterion in acceptance_criteria {
content.push_str(&format!("- [ ] {criterion}\n")); content.push_str(&format!("- [ ] {criterion}\n"));
} }
} content
},
// Write to database content store and CRDT. )
write_story_content(root, &spike_id, "1_backlog", &content, Some(name));
// Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&spike_id, depends_on.unwrap_or(&[]));
// Story 933: typed CRDT register for item_type.
crate::crdt_state::set_item_type(&spike_id, Some("spike"));
Ok(spike_id)
} }
+61 -29
View File
@@ -176,7 +176,7 @@ fn create_bug_file_writes_correct_content() {
"1. Go to /login\n2. Click submit", "1. Go to /login\n2. Click submit",
"Page crashes with 500 error", "Page crashes with 500 error",
"Login succeeds", "Login succeeds",
Some(&["Login form submits without error".to_string()]), &["Login form submits without error".to_string()],
None, None,
) )
.unwrap(); .unwrap();
@@ -226,7 +226,7 @@ fn create_bug_file_rejects_empty_name() {
"steps", "steps",
"actual", "actual",
"expected", "expected",
None, &[],
None, None,
); );
assert!(result.is_err()); assert!(result.is_err());
@@ -234,29 +234,23 @@ fn create_bug_file_rejects_empty_name() {
} }
#[test] #[test]
fn create_bug_file_uses_default_acceptance_criterion() { fn create_bug_file_rejects_empty_acceptance_criteria() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path()); let err = create_bug_file(
let bug_id = create_bug_file(
tmp.path(), tmp.path(),
"Some Bug", "Some Bug",
"desc", "desc",
"steps", "steps",
"actual", "actual",
"expected", "expected",
None, &[],
None, None,
) )
.unwrap(); .unwrap_err();
let contents = crate::db::read_content(&bug_id).expect("bug content should exist");
assert!( assert!(
contents.starts_with("---\ntype: bug\nname: \"Some Bug\"\n---"), err.contains("acceptance criterion"),
"bug file must have YAML front matter with type field" "error should mention acceptance criterion, got: {err}"
); );
assert!(contents.contains("- [ ] Bug is fixed and verified"));
} }
// ── create_spike_file tests ──────────────────────────────────────────────── // ── create_spike_file tests ────────────────────────────────────────────────
@@ -269,7 +263,7 @@ fn create_spike_file_writes_correct_content() {
tmp.path(), tmp.path(),
"Filesystem Watcher Architecture", "Filesystem Watcher Architecture",
None, None,
&[], &["Architecture documented".to_string()],
None, None,
) )
.unwrap(); .unwrap();
@@ -302,8 +296,14 @@ fn create_spike_file_uses_description_when_provided() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let description = "What is the best approach for watching filesystem events?"; let description = "What is the best approach for watching filesystem events?";
let spike_id = let spike_id = create_spike_file(
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description), &[], None).unwrap(); tmp.path(),
"FS Watcher Spike",
Some(description),
&["Findings documented".to_string()],
None,
)
.unwrap();
let contents = crate::db::read_content(&spike_id) let contents = crate::db::read_content(&spike_id)
.or_else(|| { .or_else(|| {
@@ -319,7 +319,14 @@ fn create_spike_file_uses_description_when_provided() {
#[test] #[test]
fn create_spike_file_uses_placeholder_when_no_description() { fn create_spike_file_uses_placeholder_when_no_description() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[], None).unwrap(); let spike_id = create_spike_file(
tmp.path(),
"My Spike",
None,
&["Findings documented".to_string()],
None,
)
.unwrap();
let contents = crate::db::read_content(&spike_id) let contents = crate::db::read_content(&spike_id)
.or_else(|| { .or_else(|| {
@@ -335,16 +342,22 @@ fn create_spike_file_uses_placeholder_when_no_description() {
#[test] #[test]
fn create_spike_file_rejects_empty_name() { fn create_spike_file_rejects_empty_name() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let result = create_spike_file(tmp.path(), "!!!", None, &[], None); // Name "!!!" has no alphanumeric chars — fails before AC check.
assert!(result.is_err()); let err = create_spike_file(tmp.path(), "!!!", None, &["AC".to_string()], None).unwrap_err();
assert!(result.unwrap_err().contains("alphanumeric")); assert!(err.contains("alphanumeric"), "got: {err}");
} }
#[test] #[test]
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() { fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let name = "Spike: compare \"fast\" vs slow encoders"; let name = "Spike: compare \"fast\" vs slow encoders";
let result = create_spike_file(tmp.path(), name, None, &[], None); let result = create_spike_file(
tmp.path(),
name,
None,
&["Findings documented".to_string()],
None,
);
assert!(result.is_ok(), "create_spike_file failed: {result:?}"); assert!(result.is_ok(), "create_spike_file failed: {result:?}");
let spike_id = result.unwrap(); let spike_id = result.unwrap();
@@ -364,7 +377,14 @@ fn create_spike_file_increments_from_existing_items() {
crate::db::ItemMeta::named("Existing"), crate::db::ItemMeta::named("Existing"),
); );
let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[], None).unwrap(); let spike_id = create_spike_file(
tmp.path(),
"My Spike",
None,
&["Findings documented".to_string()],
None,
)
.unwrap();
assert!( assert!(
spike_id.chars().all(|c| c.is_ascii_digit()), spike_id.chars().all(|c| c.is_ascii_digit()),
"spike ID must be numeric-only, got: {spike_id}" "spike ID must be numeric-only, got: {spike_id}"
@@ -391,7 +411,7 @@ fn create_bug_file_with_depends_on_persists_to_crdt() {
"steps", "steps",
"actual", "actual",
"expected", "expected",
None, &["Bug fixed".to_string()],
Some(&[42, 43]), Some(&[42, 43]),
) )
.unwrap(); .unwrap();
@@ -412,7 +432,7 @@ fn create_bug_file_without_depends_on_omits_field() {
"steps", "steps",
"actual", "actual",
"expected", "expected",
None, &["Bug fixed".to_string()],
None, None,
) )
.unwrap(); .unwrap();
@@ -438,8 +458,14 @@ fn create_refactor_file_with_depends_on_persists_to_crdt() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path()); setup_git_repo(tmp.path());
let refactor_id = let refactor_id = create_refactor_file(
create_refactor_file(tmp.path(), "Dep Refactor", None, None, Some(&[99])).unwrap(); tmp.path(),
"Dep Refactor",
None,
&["Refactoring complete".to_string()],
Some(&[99]),
)
.unwrap();
let view = crate::crdt_state::read_item(&refactor_id).expect("CRDT entry should exist"); let view = crate::crdt_state::read_item(&refactor_id).expect("CRDT entry should exist");
assert_eq!(view.depends_on(), &[99]); assert_eq!(view.depends_on(), &[99]);
@@ -450,8 +476,14 @@ fn create_refactor_file_without_depends_on_omits_field() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path()); setup_git_repo(tmp.path());
let refactor_id = let refactor_id = create_refactor_file(
create_refactor_file(tmp.path(), "No Dep Refactor", None, None, None).unwrap(); tmp.path(),
"No Dep Refactor",
None,
&["Refactoring complete".to_string()],
None,
)
.unwrap();
let contents = crate::db::read_content(&refactor_id) let contents = crate::db::read_content(&refactor_id)
.or_else(|| { .or_else(|| {
+3 -2
View File
@@ -19,6 +19,7 @@ pub use story_ops::{
pub use test_results::{read_test_results_from_story_file, write_test_results_to_story_file}; pub use test_results::{read_test_results_from_story_file, write_test_results_to_story_file};
pub(crate) use utils::{ pub(crate) use utils::{
create_section_content, next_item_number, read_story_content, replace_or_append_section, create_item_in_backlog, create_section_content, next_item_number, read_story_content,
replace_section_content, slugify_name, story_stage, write_story_content, replace_or_append_section, replace_section_content, slugify_name, story_stage,
write_story_content,
}; };
+67 -39
View File
@@ -1,43 +1,46 @@
//! create_story_file: write new story to CRDT/content store. //! create_story_file: write new story to CRDT/content store.
#[allow(unused_imports)] use super::super::create_item_in_backlog;
use super::super::{
create_section_content, next_item_number, read_story_content, replace_section_content,
slugify_name, story_stage, write_story_content,
};
/// Write a new story file to the CRDT content store and return the generated story ID. /// Write a new story file to the CRDT content store and return the generated story ID.
///
/// Routes through `create_item_in_backlog`, the single internal creation path.
/// Validates non-empty title and ≥ 1 acceptance criterion before writing anything.
pub fn create_story_file( pub fn create_story_file(
root: &std::path::Path, root: &std::path::Path,
name: &str, name: &str,
user_story: Option<&str>, user_story: Option<&str>,
description: Option<&str>, description: Option<&str>,
acceptance_criteria: Option<&[String]>, acceptance_criteria: &[String],
depends_on: Option<&[u32]>, depends_on: Option<&[u32]>,
_commit: bool, _commit: bool,
) -> Result<String, String> { ) -> Result<String, String> {
let story_number = next_item_number(root)?; let name_owned = name.to_string();
let slug = slugify_name(name); let user_story_owned = user_story.map(str::to_string);
let description_owned = description.map(str::to_string);
if slug.is_empty() { let depends_on_owned: Option<Vec<u32>> = depends_on.map(<[u32]>::to_vec);
return Err("Name must contain at least one alphanumeric character.".to_string()); let acs_owned: Vec<String> = acceptance_criteria.to_vec();
}
let story_id = format!("{story_number}");
create_item_in_backlog(
root,
"story",
name,
acceptance_criteria,
depends_on,
move |story_number| {
let mut content = String::new(); let mut content = String::new();
content.push_str("---\n"); content.push_str("---\n");
content.push_str("type: story\n"); content.push_str("type: story\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); content.push_str(&format!("name: \"{}\"\n", name_owned.replace('"', "\\\"")));
if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { if let Some(ref deps) = depends_on_owned.filter(|d| !d.is_empty()) {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect(); let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); content.push_str(&format!("depends_on: [{}]\n", nums.join(", ")));
} }
content.push_str("---\n\n"); content.push_str("---\n\n");
content.push_str(&format!("# Story {story_number}: {name}\n\n")); content.push_str(&format!("# Story {story_number}: {name_owned}\n\n"));
content.push_str("## User Story\n\n"); content.push_str("## User Story\n\n");
if let Some(us) = user_story { if let Some(ref us) = user_story_owned {
content.push_str(us); content.push_str(us);
content.push('\n'); content.push('\n');
} else { } else {
@@ -45,7 +48,7 @@ pub fn create_story_file(
} }
content.push('\n'); content.push('\n');
if let Some(desc) = description { if let Some(ref desc) = description_owned {
content.push_str("## Description\n\n"); content.push_str("## Description\n\n");
content.push_str(desc); content.push_str(desc);
content.push('\n'); content.push('\n');
@@ -53,28 +56,16 @@ pub fn create_story_file(
} }
content.push_str("## Acceptance Criteria\n\n"); content.push_str("## Acceptance Criteria\n\n");
if let Some(criteria) = acceptance_criteria { for criterion in &acs_owned {
for criterion in criteria {
content.push_str(&format!("- [ ] {criterion}\n")); content.push_str(&format!("- [ ] {criterion}\n"));
} }
} else {
content.push_str("- [ ] TODO\n");
}
content.push('\n'); content.push('\n');
content.push_str("## Out of Scope\n\n"); content.push_str("## Out of Scope\n\n");
content.push_str("- TBD\n"); content.push_str("- TBD\n");
content
// Write to database content store and CRDT. },
write_story_content(root, &story_id, "1_backlog", &content, Some(name)); )
// Sync depends_on to the typed CRDT register.
crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[]));
// Story 933: typed CRDT register for item_type.
crate::crdt_state::set_item_type(&story_id, Some("story"));
Ok(story_id)
} }
/// Check off the Nth unchecked acceptance criterion in a story. /// Check off the Nth unchecked acceptance criterion in a story.
@@ -187,7 +178,8 @@ mod tests {
crate::crdt_state::init_for_test(); crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let name = "Server-owned agent completion: remove report_completion dependency"; let name = "Server-owned agent completion: remove report_completion dependency";
let result = create_story_file(tmp.path(), name, None, None, None, None, false); let acs = vec!["Completion handled server-side".to_string()];
let result = create_story_file(tmp.path(), name, None, None, &acs, None, false);
assert!(result.is_ok(), "create_story_file failed: {result:?}"); assert!(result.is_ok(), "create_story_file failed: {result:?}");
let story_id = result.unwrap(); let story_id = result.unwrap();
@@ -202,12 +194,13 @@ mod tests {
fn create_story_with_depends_on_persists_to_crdt() { fn create_story_with_depends_on_persists_to_crdt() {
crate::crdt_state::init_for_test(); crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let acs = vec!["Dependent criterion".to_string()];
let story_id = create_story_file( let story_id = create_story_file(
tmp.path(), tmp.path(),
"Dependent Story", "Dependent Story",
None, None,
None, None,
None, &acs,
Some(&[489]), Some(&[489]),
false, false,
) )
@@ -224,7 +217,8 @@ mod tests {
fn create_story_file_returns_numeric_only_id() { fn create_story_file_returns_numeric_only_id() {
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let result = create_story_file(tmp.path(), "My Feature", None, None, None, None, false); let acs = vec!["Feature works".to_string()];
let result = create_story_file(tmp.path(), "My Feature", None, None, &acs, None, false);
assert!( assert!(
result.is_ok(), result.is_ok(),
"create_story_file should succeed: {result:?}" "create_story_file should succeed: {result:?}"
@@ -241,8 +235,9 @@ mod tests {
crate::crdt_state::init_for_test(); crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let acs = vec!["Type validated".to_string()];
let story_id = let story_id =
create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false) create_story_file(tmp.path(), "Type Test Story", None, None, &acs, None, false)
.unwrap(); .unwrap();
let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist"); let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist");
assert_eq!( assert_eq!(
@@ -252,5 +247,38 @@ mod tests {
); );
} }
#[test]
fn create_story_file_rejects_empty_title() {
let tmp = tempfile::tempdir().unwrap();
let acs = vec!["Some criterion".to_string()];
let err = create_story_file(tmp.path(), "", None, None, &acs, None, false).unwrap_err();
assert!(
err.contains("empty") || err.contains("whitespace"),
"error should mention empty/whitespace, got: {err}"
);
}
#[test]
fn create_story_file_rejects_whitespace_only_title() {
let tmp = tempfile::tempdir().unwrap();
let acs = vec!["Some criterion".to_string()];
let err = create_story_file(tmp.path(), " ", None, None, &acs, None, false).unwrap_err();
assert!(
err.contains("empty") || err.contains("whitespace"),
"error should mention empty/whitespace, got: {err}"
);
}
#[test]
fn create_story_file_rejects_empty_acceptance_criteria() {
let tmp = tempfile::tempdir().unwrap();
let result = create_story_file(tmp.path(), "Valid Title", None, None, &[], None, false);
assert!(result.is_err(), "empty ACs should be rejected");
assert!(
result.unwrap_err().contains("acceptance criterion"),
"error should mention acceptance criterion"
);
}
// ── Story 504: native JSON types in front_matter ─────────────────────────── // ── Story 504: native JSON types in front_matter ───────────────────────────
} }
+49
View File
@@ -222,6 +222,55 @@ pub(crate) fn next_item_number(_root: &std::path::Path) -> Result<u32, String> {
Ok(crate::db::next_item_number()) Ok(crate::db::next_item_number())
} }
/// Single internal entry point for creating a new pipeline work item in the backlog.
///
/// This is the canonical creation path. All `create_*_file` functions for pipeline
/// item types (story, bug, spike, refactor) route through here. On validation failure
/// this function returns `Err` and writes nothing.
///
/// Validates:
/// - `name` is not empty or whitespace-only
/// - `name` contains at least one alphanumeric character
/// - `acceptance_criteria` has at least one entry
/// - `item_type` is a known pipeline item type
///
/// `build_content` receives the assigned item number and returns the full markdown
/// content to persist (including front matter and all type-specific sections).
pub(crate) fn create_item_in_backlog(
root: &Path,
item_type: &str,
name: &str,
acceptance_criteria: &[String],
depends_on: Option<&[u32]>,
build_content: impl FnOnce(u32) -> String,
) -> Result<String, String> {
if name.trim().is_empty() {
return Err("Title must not be empty or whitespace-only.".to_string());
}
if slugify_name(name).is_empty() {
return Err("Title must contain at least one alphanumeric character.".to_string());
}
if acceptance_criteria.is_empty() {
return Err("At least one acceptance criterion is required.".to_string());
}
const VALID_TYPES: &[&str] = &["story", "bug", "spike", "refactor"];
if !VALID_TYPES.contains(&item_type) {
return Err(format!(
"Invalid item type '{item_type}': must be one of story, bug, spike, refactor."
));
}
let item_number = next_item_number(root)?;
let item_id = format!("{item_number}");
let content = build_content(item_number);
write_story_content(root, &item_id, "1_backlog", &content, Some(name));
crate::crdt_state::set_depends_on(&item_id, depends_on.unwrap_or(&[]));
crate::crdt_state::set_item_type(&item_id, Some(item_type));
Ok(item_id)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;