2026-03-22 19:07:07 +00:00
use crate ::io ::story_metadata ::parse_front_matter ;
use std ::path ::Path ;
2026-04-10 14:56:13 +00:00
use super ::{ next_item_number , slugify_name , write_story_content } ;
2026-03-22 19:07:07 +00:00
2026-04-08 03:03:59 +00:00
/// Create a bug file and store it in the database.
2026-03-22 19:07:07 +00:00
///
2026-04-08 03:03:59 +00:00
/// Also writes to the filesystem for backwards compatibility during migration.
2026-03-22 19:07:07 +00:00
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
pub fn create_bug_file (
root : & Path ,
name : & str ,
description : & str ,
steps_to_reproduce : & str ,
actual_result : & str ,
expected_result : & str ,
acceptance_criteria : Option < & [ String ] > ,
) -> Result < String , String > {
let bug_number = next_item_number ( root ) ? ;
let slug = slugify_name ( name ) ;
if slug . is_empty ( ) {
return Err ( " Name must contain at least one alphanumeric character. " . to_string ( ) ) ;
}
2026-04-08 03:03:59 +00:00
let bug_id = format! ( " {bug_number} _bug_ {slug} " ) ;
2026-03-22 19:07:07 +00:00
let mut content = String ::new ( ) ;
content . push_str ( " --- \n " ) ;
content . push_str ( & format! ( " name: \" {} \" \n " , name . replace ( '"' , " \\ \" " ) ) ) ;
content . push_str ( " --- \n \n " ) ;
content . push_str ( & format! ( " # Bug {bug_number} : {name} \n \n " ) ) ;
content . push_str ( " ## Description \n \n " ) ;
content . push_str ( description ) ;
content . push_str ( " \n \n " ) ;
content . push_str ( " ## How to Reproduce \n \n " ) ;
content . push_str ( steps_to_reproduce ) ;
content . push_str ( " \n \n " ) ;
content . push_str ( " ## Actual Result \n \n " ) ;
content . push_str ( actual_result ) ;
content . push_str ( " \n \n " ) ;
content . push_str ( " ## Expected Result \n \n " ) ;
content . push_str ( expected_result ) ;
content . push_str ( " \n \n " ) ;
content . push_str ( " ## Acceptance Criteria \n \n " ) ;
if let Some ( criteria ) = acceptance_criteria {
for criterion in criteria {
content . push_str ( & format! ( " - [ ] {criterion} \n " ) ) ;
}
} else {
content . push_str ( " - [ ] Bug is fixed and verified \n " ) ;
}
2026-04-10 14:56:13 +00:00
// Write to database content store and CRDT.
write_story_content ( root , & bug_id , " 1_backlog " , & content ) ;
2026-03-22 19:07:07 +00:00
Ok ( bug_id )
}
2026-04-08 03:03:59 +00:00
/// Create a spike file and store it in the database.
2026-03-22 19:07:07 +00:00
///
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
pub fn create_spike_file (
root : & Path ,
name : & str ,
description : Option < & str > ,
) -> Result < String , String > {
let spike_number = next_item_number ( root ) ? ;
let slug = slugify_name ( name ) ;
if slug . is_empty ( ) {
return Err ( " Name must contain at least one alphanumeric character. " . to_string ( ) ) ;
}
2026-04-08 03:03:59 +00:00
let spike_id = format! ( " {spike_number} _spike_ {slug} " ) ;
2026-03-22 19:07:07 +00:00
let mut content = String ::new ( ) ;
content . push_str ( " --- \n " ) ;
content . push_str ( & format! ( " name: \" {} \" \n " , name . replace ( '"' , " \\ \" " ) ) ) ;
content . push_str ( " --- \n \n " ) ;
content . push_str ( & format! ( " # Spike {spike_number} : {name} \n \n " ) ) ;
content . push_str ( " ## Question \n \n " ) ;
if let Some ( desc ) = description {
content . push_str ( desc ) ;
content . push ( '\n' ) ;
} else {
content . push_str ( " - TBD \n " ) ;
}
content . push ( '\n' ) ;
content . push_str ( " ## Hypothesis \n \n " ) ;
content . push_str ( " - TBD \n \n " ) ;
content . push_str ( " ## Timebox \n \n " ) ;
content . push_str ( " - TBD \n \n " ) ;
content . push_str ( " ## Investigation Plan \n \n " ) ;
content . push_str ( " - TBD \n \n " ) ;
content . push_str ( " ## Findings \n \n " ) ;
content . push_str ( " - TBD \n \n " ) ;
content . push_str ( " ## Recommendation \n \n " ) ;
content . push_str ( " - TBD \n " ) ;
2026-04-10 14:56:13 +00:00
// Write to database content store and CRDT.
write_story_content ( root , & spike_id , " 1_backlog " , & content ) ;
2026-03-22 19:07:07 +00:00
Ok ( spike_id )
}
2026-04-08 03:03:59 +00:00
/// Create a refactor work item and store it in the database.
2026-03-22 19:07:07 +00:00
///
/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`).
pub fn create_refactor_file (
root : & Path ,
name : & str ,
description : Option < & str > ,
acceptance_criteria : Option < & [ String ] > ,
) -> Result < String , String > {
let refactor_number = next_item_number ( root ) ? ;
let slug = slugify_name ( name ) ;
if slug . is_empty ( ) {
return Err ( " Name must contain at least one alphanumeric character. " . to_string ( ) ) ;
}
2026-04-08 03:03:59 +00:00
let refactor_id = format! ( " {refactor_number} _refactor_ {slug} " ) ;
2026-03-22 19:07:07 +00:00
let mut content = String ::new ( ) ;
content . push_str ( " --- \n " ) ;
content . push_str ( & format! ( " name: \" {} \" \n " , name . replace ( '"' , " \\ \" " ) ) ) ;
content . push_str ( " --- \n \n " ) ;
content . push_str ( & format! ( " # Refactor {refactor_number} : {name} \n \n " ) ) ;
content . push_str ( " ## Current State \n \n " ) ;
content . push_str ( " - TBD \n \n " ) ;
content . push_str ( " ## Desired State \n \n " ) ;
if let Some ( desc ) = description {
content . push_str ( desc ) ;
content . push ( '\n' ) ;
} else {
content . push_str ( " - TBD \n " ) ;
}
content . push ( '\n' ) ;
content . push_str ( " ## Acceptance Criteria \n \n " ) ;
if let Some ( criteria ) = acceptance_criteria {
for criterion in criteria {
content . push_str ( & format! ( " - [ ] {criterion} \n " ) ) ;
}
} else {
content . push_str ( " - [ ] Refactoring complete and all tests pass \n " ) ;
}
content . push ( '\n' ) ;
content . push_str ( " ## Out of Scope \n \n " ) ;
content . push_str ( " - TBD \n " ) ;
2026-04-10 14:56:13 +00:00
// Write to database content store and CRDT.
write_story_content ( root , & refactor_id , " 1_backlog " , & content ) ;
2026-03-22 19:07:07 +00:00
Ok ( refactor_id )
}
/// Returns true if the item stem (filename without extension) is a bug item.
fn is_bug_item ( stem : & str ) -> bool {
let after_num = stem . trim_start_matches ( | c : char | c . is_ascii_digit ( ) ) ;
after_num . starts_with ( " _bug_ " )
}
2026-04-08 03:03:59 +00:00
/// Extract bug name from content (heading or front matter).
fn extract_bug_name_from_content ( content : & str ) -> Option < String > {
// Try front matter first.
if let Ok ( meta ) = parse_front_matter ( content ) & & let Some ( name ) = meta . name {
return Some ( name ) ;
}
// Fallback: heading.
for line in content . lines ( ) {
if let Some ( rest ) = line . strip_prefix ( " # Bug " ) & & let Some ( colon_pos ) = rest . find ( " : " ) {
return Some ( rest [ colon_pos + 2 .. ] . to_string ( ) ) ;
2026-03-22 19:07:07 +00:00
}
}
None
}
2026-04-10 14:56:13 +00:00
/// List all open bugs from CRDT + content store.
2026-03-22 19:07:07 +00:00
///
/// Returns a sorted list of `(bug_id, name)` pairs.
2026-04-10 14:56:13 +00:00
pub fn list_bug_files ( _root : & Path ) -> Result < Vec < ( String , String ) > , String > {
2026-03-22 19:07:07 +00:00
let mut bugs = Vec ::new ( ) ;
2026-04-09 21:24:11 +00:00
for item in crate ::pipeline_state ::read_all_typed ( ) {
if ! matches! ( item . stage , crate ::pipeline_state ::Stage ::Backlog ) | | ! is_bug_item ( & item . story_id . 0 ) {
continue ;
2026-03-22 19:07:07 +00:00
}
2026-04-09 21:24:11 +00:00
let sid = item . story_id . 0 ;
let name = if item . name . is_empty ( ) { None } else { Some ( item . name ) }
. or_else ( | | {
crate ::db ::read_content ( & sid )
. and_then ( | c | extract_bug_name_from_content ( & c ) )
} )
. unwrap_or_else ( | | sid . clone ( ) ) ;
bugs . push ( ( sid , name ) ) ;
2026-04-08 03:03:59 +00:00
}
2026-03-22 19:07:07 +00:00
bugs . sort_by ( | a , b | a . 0. cmp ( & b . 0 ) ) ;
Ok ( bugs )
}
2026-04-08 03:03:59 +00:00
/// Returns true if the item stem is a refactor item.
2026-03-22 19:07:07 +00:00
fn is_refactor_item ( stem : & str ) -> bool {
let after_num = stem . trim_start_matches ( | c : char | c . is_ascii_digit ( ) ) ;
after_num . starts_with ( " _refactor_ " )
}
2026-04-10 14:56:13 +00:00
/// List all open refactors from CRDT + content store.
2026-03-22 19:07:07 +00:00
///
/// Returns a sorted list of `(refactor_id, name)` pairs.
2026-04-10 14:56:13 +00:00
pub fn list_refactor_files ( _root : & Path ) -> Result < Vec < ( String , String ) > , String > {
2026-03-22 19:07:07 +00:00
let mut refactors = Vec ::new ( ) ;
2026-04-09 21:24:11 +00:00
for item in crate ::pipeline_state ::read_all_typed ( ) {
if ! matches! ( item . stage , crate ::pipeline_state ::Stage ::Backlog ) | | ! is_refactor_item ( & item . story_id . 0 ) {
continue ;
2026-03-22 19:07:07 +00:00
}
2026-04-09 21:24:11 +00:00
let sid = item . story_id . 0 ;
let name = if item . name . is_empty ( ) { None } else { Some ( item . name ) }
. or_else ( | | {
crate ::db ::read_content ( & sid )
. and_then ( | c | parse_front_matter ( & c ) . ok ( ) )
. and_then ( | m | m . name )
} )
. unwrap_or_else ( | | sid . clone ( ) ) ;
refactors . push ( ( sid , name ) ) ;
2026-04-08 03:03:59 +00:00
}
2026-03-22 19:07:07 +00:00
refactors . sort_by ( | a , b | a . 0. cmp ( & b . 0 ) ) ;
Ok ( refactors )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-04-10 14:56:13 +00:00
use std ::fs ;
2026-03-22 19:07:07 +00:00
fn setup_git_repo ( root : & std ::path ::Path ) {
std ::process ::Command ::new ( " git " )
. args ( [ " init " ] )
. current_dir ( root )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " config " , " user.email " , " test@test.com " ] )
. current_dir ( root )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " config " , " user.name " , " Test " ] )
. current_dir ( root )
. output ( )
. unwrap ( ) ;
std ::process ::Command ::new ( " git " )
. args ( [ " commit " , " --allow-empty " , " -m " , " init " ] )
. current_dir ( root )
. output ( )
. unwrap ( ) ;
}
// ── Bug file helper tests ──────────────────────────────────────────────────
#[ test ]
fn next_item_number_starts_at_1_when_empty_bugs ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-08 03:03:59 +00:00
assert! ( super ::super ::next_item_number ( tmp . path ( ) ) . unwrap ( ) > = 1 ) ;
2026-03-22 19:07:07 +00:00
}
#[ test ]
fn next_item_number_increments_from_existing_bugs ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-03 16:12:52 +01:00
let backlog = tmp . path ( ) . join ( " .huskies/work/1_backlog " ) ;
2026-03-22 19:07:07 +00:00
fs ::create_dir_all ( & backlog ) . unwrap ( ) ;
fs ::write ( backlog . join ( " 1_bug_crash.md " ) , " " ) . unwrap ( ) ;
fs ::write ( backlog . join ( " 3_bug_another.md " ) , " " ) . unwrap ( ) ;
2026-04-08 03:03:59 +00:00
assert! ( super ::super ::next_item_number ( tmp . path ( ) ) . unwrap ( ) > = 4 ) ;
2026-03-22 19:07:07 +00:00
}
#[ test ]
fn next_item_number_scans_archived_too ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-03 16:12:52 +01:00
let backlog = tmp . path ( ) . join ( " .huskies/work/1_backlog " ) ;
let archived = tmp . path ( ) . join ( " .huskies/work/5_done " ) ;
2026-03-22 19:07:07 +00:00
fs ::create_dir_all ( & backlog ) . unwrap ( ) ;
fs ::create_dir_all ( & archived ) . unwrap ( ) ;
fs ::write ( archived . join ( " 5_bug_old.md " ) , " " ) . unwrap ( ) ;
2026-04-08 03:03:59 +00:00
assert! ( super ::super ::next_item_number ( tmp . path ( ) ) . unwrap ( ) > = 6 ) ;
2026-03-22 19:07:07 +00:00
}
#[ test ]
2026-04-10 14:56:13 +00:00
fn list_bug_files_no_crash_on_missing_dir ( ) {
// list_bug_files now reads from the global CRDT, not the filesystem.
// Verify it does not panic when called with a non-existent project root.
2026-03-22 19:07:07 +00:00
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
let result = list_bug_files ( tmp . path ( ) ) ;
assert! ( result . is_ok ( ) ) ;
2026-03-22 19:07:07 +00:00
}
#[ test ]
fn list_bug_files_excludes_archive_subdir ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
crate ::db ::ensure_content_store ( ) ;
// Bug in backlog (should appear).
crate ::db ::write_item_with_content (
" 7001_bug_open " ,
" 1_backlog " ,
" --- \n name: Open Bug \n --- \n # Bug 7001: Open Bug \n " ,
) ;
// Bug in done (should NOT appear — list_bug_files only returns Backlog).
crate ::db ::write_item_with_content (
" 7002_bug_closed " ,
" 5_done " ,
" --- \n name: Closed Bug \n --- \n # Bug 7002: Closed Bug \n " ,
) ;
2026-03-22 19:07:07 +00:00
let result = list_bug_files ( tmp . path ( ) ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
assert! ( result . iter ( ) . any ( | ( id , name ) | id = = " 7001_bug_open " & & name = = " Open Bug " ) ) ;
assert! ( ! result . iter ( ) . any ( | ( id , _ ) | id = = " 7002_bug_closed " ) ) ;
2026-03-22 19:07:07 +00:00
}
#[ test ]
fn list_bug_files_sorted_by_id ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
crate ::db ::ensure_content_store ( ) ;
crate ::db ::write_item_with_content (
" 7013_bug_third " ,
" 1_backlog " ,
" --- \n name: Third \n --- \n # Bug 7013: Third \n " ,
) ;
crate ::db ::write_item_with_content (
" 7011_bug_first " ,
" 1_backlog " ,
" --- \n name: First \n --- \n # Bug 7011: First \n " ,
) ;
crate ::db ::write_item_with_content (
" 7012_bug_second " ,
" 1_backlog " ,
" --- \n name: Second \n --- \n # Bug 7012: Second \n " ,
) ;
2026-03-22 19:07:07 +00:00
let result = list_bug_files ( tmp . path ( ) ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
// Find positions of our three bugs in the sorted result.
let pos_first = result . iter ( ) . position ( | ( id , _ ) | id = = " 7011_bug_first " ) . unwrap ( ) ;
let pos_second = result . iter ( ) . position ( | ( id , _ ) | id = = " 7012_bug_second " ) . unwrap ( ) ;
let pos_third = result . iter ( ) . position ( | ( id , _ ) | id = = " 7013_bug_third " ) . unwrap ( ) ;
assert! ( pos_first < pos_second ) ;
assert! ( pos_second < pos_third ) ;
2026-03-22 19:07:07 +00:00
}
#[ test ]
2026-04-08 03:03:59 +00:00
fn extract_bug_name_from_content_parses_heading ( ) {
let content = " # Bug 1: Login page crashes \n \n ## Description \n " ;
let name = extract_bug_name_from_content ( content ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
assert_eq! ( name , " Login page crashes " ) ;
}
#[ test ]
fn create_bug_file_writes_correct_content ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo ( tmp . path ( ) ) ;
let bug_id = create_bug_file (
tmp . path ( ) ,
" Login Crash " ,
" The login page crashes on submit. " ,
" 1. Go to /login \n 2. Click submit " ,
" Page crashes with 500 error " ,
" Login succeeds " ,
Some ( & [ " Login form submits without error " . to_string ( ) ] ) ,
)
. unwrap ( ) ;
2026-04-08 03:03:59 +00:00
assert! ( bug_id . ends_with ( " _bug_login_crash " ) , " expected ID to end with _bug_login_crash, got: {bug_id} " ) ;
// Check content exists (either in DB or filesystem).
let contents = crate ::db ::read_content ( & bug_id )
. or_else ( | | {
let filepath = tmp . path ( ) . join ( format! ( " .huskies/work/1_backlog/ {bug_id} .md " ) ) ;
fs ::read_to_string ( filepath ) . ok ( )
} )
. expect ( " bug content should exist " ) ;
2026-03-22 19:07:07 +00:00
assert! (
contents . starts_with ( " --- \n name: \" Login Crash \" \n --- " ) ,
" bug file must start with YAML front matter "
) ;
2026-04-08 03:03:59 +00:00
assert! ( contents . contains ( " Login Crash " ) , " content should mention bug name " ) ;
2026-03-22 19:07:07 +00:00
assert! ( contents . contains ( " ## Description " ) ) ;
assert! ( contents . contains ( " The login page crashes on submit. " ) ) ;
assert! ( contents . contains ( " ## How to Reproduce " ) ) ;
assert! ( contents . contains ( " 1. Go to /login " ) ) ;
assert! ( contents . contains ( " ## Actual Result " ) ) ;
assert! ( contents . contains ( " Page crashes with 500 error " ) ) ;
assert! ( contents . contains ( " ## Expected Result " ) ) ;
assert! ( contents . contains ( " Login succeeds " ) ) ;
assert! ( contents . contains ( " ## Acceptance Criteria " ) ) ;
assert! ( contents . contains ( " - [ ] Login form submits without error " ) ) ;
}
#[ test ]
fn create_bug_file_rejects_empty_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let result = create_bug_file ( tmp . path ( ) , " !!! " , " desc " , " steps " , " actual " , " expected " , None ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " alphanumeric " ) ) ;
}
#[ test ]
fn create_bug_file_uses_default_acceptance_criterion ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
setup_git_repo ( tmp . path ( ) ) ;
2026-04-08 03:03:59 +00:00
let bug_id = create_bug_file (
2026-03-22 19:07:07 +00:00
tmp . path ( ) ,
" Some Bug " ,
" desc " ,
" steps " ,
" actual " ,
" expected " ,
None ,
)
. unwrap ( ) ;
2026-04-08 03:03:59 +00:00
let contents = crate ::db ::read_content ( & bug_id )
. or_else ( | | {
let filepath = tmp . path ( ) . join ( " .huskies/work/1_backlog/1_bug_some_bug.md " ) ;
fs ::read_to_string ( filepath ) . ok ( )
} )
. expect ( " bug content should exist " ) ;
2026-03-22 19:07:07 +00:00
assert! (
contents . starts_with ( " --- \n name: \" Some Bug \" \n --- " ) ,
" bug file must have YAML front matter "
) ;
assert! ( contents . contains ( " - [ ] Bug is fixed and verified " ) ) ;
}
// ── create_spike_file tests ────────────────────────────────────────────────
#[ test ]
fn create_spike_file_writes_correct_content ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let spike_id =
create_spike_file ( tmp . path ( ) , " Filesystem Watcher Architecture " , None ) . unwrap ( ) ;
2026-04-08 03:03:59 +00:00
assert! ( spike_id . ends_with ( " _spike_filesystem_watcher_architecture " ) , " expected ID to end with _spike_filesystem_watcher_architecture, got: {spike_id} " ) ;
let contents = crate ::db ::read_content ( & spike_id )
. or_else ( | | {
let filepath = tmp . path ( ) . join ( format! ( " .huskies/work/1_backlog/ {spike_id} .md " ) ) ;
fs ::read_to_string ( filepath ) . ok ( )
} )
. expect ( " spike content should exist " ) ;
2026-03-22 19:07:07 +00:00
assert! (
contents . starts_with ( " --- \n name: \" Filesystem Watcher Architecture \" \n --- " ) ,
" spike file must start with YAML front matter "
) ;
2026-04-08 03:03:59 +00:00
assert! ( contents . contains ( " Filesystem Watcher Architecture " ) , " content should mention spike name " ) ;
2026-03-22 19:07:07 +00:00
assert! ( contents . contains ( " ## Question " ) ) ;
assert! ( contents . contains ( " ## Hypothesis " ) ) ;
assert! ( contents . contains ( " ## Timebox " ) ) ;
assert! ( contents . contains ( " ## Investigation Plan " ) ) ;
assert! ( contents . contains ( " ## Findings " ) ) ;
assert! ( contents . contains ( " ## Recommendation " ) ) ;
}
#[ test ]
fn create_spike_file_uses_description_when_provided ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let description = " What is the best approach for watching filesystem events? " ;
2026-04-08 03:03:59 +00:00
let spike_id = create_spike_file ( tmp . path ( ) , " FS Watcher Spike " , Some ( description ) ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-08 03:03:59 +00:00
let contents = crate ::db ::read_content ( & spike_id )
. or_else ( | | {
let filepath = tmp . path ( ) . join ( format! ( " .huskies/work/1_backlog/ {spike_id} .md " ) ) ;
fs ::read_to_string ( filepath ) . ok ( )
} )
. expect ( " spike content should exist " ) ;
2026-03-22 19:07:07 +00:00
assert! ( contents . contains ( description ) ) ;
}
#[ test ]
fn create_spike_file_uses_placeholder_when_no_description ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-08 03:03:59 +00:00
let spike_id = create_spike_file ( tmp . path ( ) , " My Spike " , None ) . unwrap ( ) ;
2026-03-22 19:07:07 +00:00
2026-04-08 03:03:59 +00:00
let contents = crate ::db ::read_content ( & spike_id )
. or_else ( | | {
let filepath = tmp . path ( ) . join ( format! ( " .huskies/work/1_backlog/ {spike_id} .md " ) ) ;
fs ::read_to_string ( filepath ) . ok ( )
} )
. expect ( " spike content should exist " ) ;
2026-03-22 19:07:07 +00:00
assert! ( contents . contains ( " ## Question \n \n - TBD \n " ) ) ;
}
#[ test ]
fn create_spike_file_rejects_empty_name ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let result = create_spike_file ( tmp . path ( ) , " !!! " , None ) ;
assert! ( result . is_err ( ) ) ;
assert! ( result . unwrap_err ( ) . contains ( " alphanumeric " ) ) ;
}
#[ test ]
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
let name = " Spike: compare \" fast \" vs slow encoders " ;
let result = create_spike_file ( tmp . path ( ) , name , None ) ;
assert! ( result . is_ok ( ) , " create_spike_file failed: {result:?} " ) ;
let spike_id = result . unwrap ( ) ;
2026-04-08 03:03:59 +00:00
let contents = crate ::db ::read_content ( & spike_id )
. or_else ( | | {
let backlog = tmp . path ( ) . join ( " .huskies/work/1_backlog " ) ;
fs ::read_to_string ( backlog . join ( format! ( " {spike_id} .md " ) ) ) . ok ( )
} )
. expect ( " spike content should exist " ) ;
2026-03-22 19:07:07 +00:00
let meta = parse_front_matter ( & contents ) . expect ( " front matter should be valid YAML " ) ;
assert_eq! ( meta . name . as_deref ( ) , Some ( name ) ) ;
}
#[ test ]
fn create_spike_file_increments_from_existing_items ( ) {
let tmp = tempfile ::tempdir ( ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
crate ::db ::ensure_content_store ( ) ;
// Seed a high-numbered item into the CRDT so next_item_number goes beyond it.
crate ::db ::write_item_with_content (
" 7050_story_existing " ,
" 1_backlog " ,
" --- \n name: Existing \n --- \n " ,
) ;
2026-03-22 19:07:07 +00:00
let spike_id = create_spike_file ( tmp . path ( ) , " My Spike " , None ) . unwrap ( ) ;
2026-04-08 03:03:59 +00:00
assert! ( spike_id . ends_with ( " _spike_my_spike " ) , " expected ID to end with _spike_my_spike, got: {spike_id} " ) ;
let num : u32 = spike_id . chars ( ) . take_while ( | c | c . is_ascii_digit ( ) ) . collect ::< String > ( ) . parse ( ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
assert! ( num > = 7051 , " expected spike number >= 7051, got: {spike_id} " ) ;
2026-03-22 19:07:07 +00:00
}
}