From db65271587016301cf173adb4498445ff03310f2 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 29 Apr 2026 15:03:07 +0000 Subject: [PATCH] huskies: merge 842 --- server/src/http/workflow/bug_ops.rs | 781 ------------------- server/src/http/workflow/bug_ops/bug.rs | 133 ++++ server/src/http/workflow/bug_ops/mod.rs | 12 + server/src/http/workflow/bug_ops/refactor.rs | 114 +++ server/src/http/workflow/bug_ops/spike.rs | 62 ++ server/src/http/workflow/bug_ops/tests.rs | 487 ++++++++++++ 6 files changed, 808 insertions(+), 781 deletions(-) delete mode 100644 server/src/http/workflow/bug_ops.rs create mode 100644 server/src/http/workflow/bug_ops/bug.rs create mode 100644 server/src/http/workflow/bug_ops/mod.rs create mode 100644 server/src/http/workflow/bug_ops/refactor.rs create mode 100644 server/src/http/workflow/bug_ops/spike.rs create mode 100644 server/src/http/workflow/bug_ops/tests.rs diff --git a/server/src/http/workflow/bug_ops.rs b/server/src/http/workflow/bug_ops.rs deleted file mode 100644 index e7664092..00000000 --- a/server/src/http/workflow/bug_ops.rs +++ /dev/null @@ -1,781 +0,0 @@ -//! Bug operations — creates bug, refactor, and spike files in the pipeline. -use crate::io::story_metadata::parse_front_matter; -use std::path::Path; - -use super::{next_item_number, slugify_name, write_story_content}; - -/// Create a bug file and store it in the database. -/// -/// Also writes to the filesystem for backwards compatibility during migration. -/// Returns the bug_id (e.g. `"4"`). -#[allow(clippy::too_many_arguments)] -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]>, - depends_on: Option<&[u32]>, -) -> Result { - 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()); - } - - let bug_id = format!("{bug_number}"); - - let mut content = String::new(); - content.push_str("---\n"); - content.push_str("type: bug\n"); - content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); - if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { - let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); - content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); - } - 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"); - } - - // Write to database content store and CRDT. - write_story_content(root, &bug_id, "1_backlog", &content); - - Ok(bug_id) -} - -/// Create a spike file and store it in the database. -/// -/// Returns the spike_id (e.g. `"4"`). -pub fn create_spike_file( - root: &Path, - name: &str, - description: Option<&str>, - acceptance_criteria: &[String], -) -> Result { - 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()); - } - - let spike_id = format!("{spike_number}"); - - let mut content = String::new(); - content.push_str("---\n"); - content.push_str("type: spike\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\n"); - content.push_str("## Acceptance Criteria\n\n"); - if acceptance_criteria.is_empty() { - content.push_str("- [ ] TBD\n"); - } else { - for criterion in acceptance_criteria { - content.push_str(&format!("- [ ] {criterion}\n")); - } - } - - // Write to database content store and CRDT. - write_story_content(root, &spike_id, "1_backlog", &content); - - Ok(spike_id) -} - -/// Create a refactor work item and store it in the database. -/// -/// Returns the refactor_id (e.g. `"5"`). -pub fn create_refactor_file( - root: &Path, - name: &str, - description: Option<&str>, - acceptance_criteria: Option<&[String]>, - depends_on: Option<&[u32]>, -) -> Result { - 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()); - } - - let refactor_id = format!("{refactor_number}"); - - let mut content = String::new(); - content.push_str("---\n"); - content.push_str("type: refactor\n"); - content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); - if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { - let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); - content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); - } - 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"); - - // Write to database content store and CRDT. - write_story_content(root, &refactor_id, "1_backlog", &content); - - Ok(refactor_id) -} - -/// Returns true if the item stem is a bug item. -/// -/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then -/// falls back to reading `type: bug` from the content store for numeric-only IDs. -fn is_bug_item(stem: &str) -> bool { - let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); - if after_num.starts_with("_bug_") { - return true; - } - // Numeric-only ID: check content store front matter. - if after_num.is_empty() { - return crate::db::read_content(stem) - .and_then(|c| parse_front_matter(&c).ok()) - .and_then(|m| m.item_type) - .map(|t| t == "bug") - .unwrap_or(false); - } - false -} - -/// Extract bug name from content (heading or front matter). -fn extract_bug_name_from_content(content: &str) -> Option { - // 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()); - } - } - None -} - -/// List all open bugs from CRDT + content store. -/// -/// Returns a sorted list of `(bug_id, name)` pairs. -pub fn list_bug_files(_root: &Path) -> Result, String> { - let mut bugs = Vec::new(); - - 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; - } - 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)); - } - - bugs.sort_by(|a, b| a.0.cmp(&b.0)); - Ok(bugs) -} - -/// Returns true if the item stem is a refactor item. -/// -/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`), then -/// falls back to reading `type: refactor` from the content store for numeric-only IDs. -fn is_refactor_item(stem: &str) -> bool { - let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); - if after_num.starts_with("_refactor_") { - return true; - } - // Numeric-only ID: check content store front matter. - if after_num.is_empty() { - return crate::db::read_content(stem) - .and_then(|c| parse_front_matter(&c).ok()) - .and_then(|m| m.item_type) - .map(|t| t == "refactor") - .unwrap_or(false); - } - false -} - -/// List all open refactors from CRDT + content store. -/// -/// Returns a sorted list of `(refactor_id, name)` pairs. -pub fn list_refactor_files(_root: &Path) -> Result, String> { - let mut refactors = Vec::new(); - - 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; - } - 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)); - } - - refactors.sort_by(|a, b| a.0.cmp(&b.0)); - Ok(refactors) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - - 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(); - assert!(super::super::next_item_number(tmp.path()).unwrap() >= 1); - } - - #[test] - fn next_item_number_increments_from_existing_bugs() { - crate::db::ensure_content_store(); - let tmp = tempfile::tempdir().unwrap(); - let backlog = tmp.path().join(".huskies/work/1_backlog"); - fs::create_dir_all(&backlog).unwrap(); - fs::write(backlog.join("1_bug_crash.md"), "").unwrap(); - fs::write(backlog.join("3_bug_another.md"), "").unwrap(); - // Also write to content store so next_item_number sees them. - crate::db::write_item_with_content("1_bug_crash", "1_backlog", "---\nname: Crash\n---\n"); - crate::db::write_item_with_content( - "3_bug_another", - "1_backlog", - "---\nname: Another\n---\n", - ); - assert!(super::super::next_item_number(tmp.path()).unwrap() >= 4); - } - - #[test] - fn next_item_number_scans_archived_too() { - crate::db::ensure_content_store(); - let tmp = tempfile::tempdir().unwrap(); - let backlog = tmp.path().join(".huskies/work/1_backlog"); - let archived = tmp.path().join(".huskies/work/5_done"); - fs::create_dir_all(&backlog).unwrap(); - fs::create_dir_all(&archived).unwrap(); - fs::write(archived.join("5_bug_old.md"), "").unwrap(); - // Also write to content store so next_item_number sees it. - crate::db::write_item_with_content("5_bug_old", "5_done", "---\nname: Old Bug\n---\n"); - assert!(super::super::next_item_number(tmp.path()).unwrap() >= 6); - } - - #[test] - 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. - let tmp = tempfile::tempdir().unwrap(); - let result = list_bug_files(tmp.path()); - assert!(result.is_ok()); - } - - #[test] - fn list_bug_files_excludes_archive_subdir() { - let tmp = tempfile::tempdir().unwrap(); - crate::db::ensure_content_store(); - // Bug in backlog (should appear). - crate::db::write_item_with_content( - "7001_bug_open", - "1_backlog", - "---\nname: 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", - "---\nname: Closed Bug\n---\n# Bug 7002: Closed Bug\n", - ); - - let result = list_bug_files(tmp.path()).unwrap(); - assert!( - result - .iter() - .any(|(id, name)| id == "7001_bug_open" && name == "Open Bug") - ); - assert!(!result.iter().any(|(id, _)| id == "7002_bug_closed")); - } - - #[test] - fn list_bug_files_sorted_by_id() { - let tmp = tempfile::tempdir().unwrap(); - crate::db::ensure_content_store(); - crate::db::write_item_with_content( - "7013_bug_third", - "1_backlog", - "---\nname: Third\n---\n# Bug 7013: Third\n", - ); - crate::db::write_item_with_content( - "7011_bug_first", - "1_backlog", - "---\nname: First\n---\n# Bug 7011: First\n", - ); - crate::db::write_item_with_content( - "7012_bug_second", - "1_backlog", - "---\nname: Second\n---\n# Bug 7012: Second\n", - ); - - let result = list_bug_files(tmp.path()).unwrap(); - // 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); - } - - #[test] - 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(); - 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\n2. Click submit", - "Page crashes with 500 error", - "Login succeeds", - Some(&["Login form submits without error".to_string()]), - None, - ) - .unwrap(); - - assert!( - bug_id.chars().all(|c| c.is_ascii_digit()), - "bug ID must be numeric-only, 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"); - - assert!( - contents.starts_with("---\ntype: bug\nname: \"Login Crash\"\n---"), - "bug file must start with YAML front matter including type field" - ); - assert!( - contents.contains("Login Crash"), - "content should mention bug name" - ); - 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, - 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()); - - let bug_id = create_bug_file( - tmp.path(), - "Some Bug", - "desc", - "steps", - "actual", - "expected", - None, - None, - ) - .unwrap(); - - let contents = crate::db::read_content(&bug_id).expect("bug content should exist"); - - assert!( - contents.starts_with("---\ntype: bug\nname: \"Some Bug\"\n---"), - "bug file must have YAML front matter with type field" - ); - 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(); - - assert!( - spike_id.chars().all(|c| c.is_ascii_digit()), - "spike ID must be numeric-only, got: {spike_id}" - ); - - let contents = crate::db::read_content(&spike_id).expect("spike content should exist"); - - assert!( - contents - .starts_with("---\ntype: spike\nname: \"Filesystem Watcher Architecture\"\n---"), - "spike file must start with YAML front matter including type field" - ); - assert!( - contents.contains("Filesystem Watcher Architecture"), - "content should mention spike name" - ); - 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?"; - - let spike_id = - create_spike_file(tmp.path(), "FS Watcher Spike", Some(description), &[]).unwrap(); - - 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"); - assert!(contents.contains(description)); - } - - #[test] - fn create_spike_file_uses_placeholder_when_no_description() { - let tmp = tempfile::tempdir().unwrap(); - let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap(); - - 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"); - 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(); - 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"); - - 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(); - 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", - "---\nname: Existing\n---\n", - ); - - let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap(); - assert!( - spike_id.chars().all(|c| c.is_ascii_digit()), - "spike ID must be numeric-only, got: {spike_id}" - ); - let num: u32 = spike_id.parse().unwrap(); - assert!( - num >= 7051, - "expected spike number >= 7051, got: {spike_id}" - ); - } - - // ── Bug 640: create_bug_file / create_refactor_file depends_on tests ──────── - - #[test] - fn create_bug_file_with_depends_on_writes_front_matter_array() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo(tmp.path()); - - let bug_id = create_bug_file( - tmp.path(), - "Dep Bug", - "desc", - "steps", - "actual", - "expected", - None, - Some(&[42, 43]), - ) - .unwrap(); - - 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"); - - assert!( - contents.contains("depends_on: [42, 43]"), - "front matter should contain depends_on array: {contents}" - ); - assert!( - !contents.contains("depends_on: \"["), - "depends_on must not be quoted string: {contents}" - ); - - let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); - assert_eq!(meta.depends_on, Some(vec![42, 43])); - } - - #[test] - fn create_bug_file_without_depends_on_omits_field() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo(tmp.path()); - - let bug_id = create_bug_file( - tmp.path(), - "No Dep Bug", - "desc", - "steps", - "actual", - "expected", - None, - None, - ) - .unwrap(); - - 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"); - - assert!( - !contents.contains("depends_on"), - "front matter must not contain depends_on when not provided: {contents}" - ); - } - - #[test] - fn create_refactor_file_with_depends_on_writes_front_matter_array() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo(tmp.path()); - - let refactor_id = - create_refactor_file(tmp.path(), "Dep Refactor", None, None, Some(&[99])).unwrap(); - - let contents = crate::db::read_content(&refactor_id) - .or_else(|| { - let filepath = tmp - .path() - .join(format!(".huskies/work/1_backlog/{refactor_id}.md")); - fs::read_to_string(filepath).ok() - }) - .expect("refactor content should exist"); - - assert!( - contents.contains("depends_on: [99]"), - "front matter should contain depends_on array: {contents}" - ); - - let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); - assert_eq!(meta.depends_on, Some(vec![99])); - } - - #[test] - fn create_refactor_file_without_depends_on_omits_field() { - let tmp = tempfile::tempdir().unwrap(); - setup_git_repo(tmp.path()); - - let refactor_id = - create_refactor_file(tmp.path(), "No Dep Refactor", None, None, None).unwrap(); - - let contents = crate::db::read_content(&refactor_id) - .or_else(|| { - let filepath = tmp - .path() - .join(format!(".huskies/work/1_backlog/{refactor_id}.md")); - fs::read_to_string(filepath).ok() - }) - .expect("refactor content should exist"); - - assert!( - !contents.contains("depends_on"), - "front matter must not contain depends_on when not provided: {contents}" - ); - } -} diff --git a/server/src/http/workflow/bug_ops/bug.rs b/server/src/http/workflow/bug_ops/bug.rs new file mode 100644 index 00000000..4f7a3df9 --- /dev/null +++ b/server/src/http/workflow/bug_ops/bug.rs @@ -0,0 +1,133 @@ +//! Bug-item creation and listing operations. + +use crate::io::story_metadata::parse_front_matter; +use std::path::Path; + +use super::super::{next_item_number, slugify_name, write_story_content}; + +/// Create a bug file and store it in the database. +/// +/// Also writes to the filesystem for backwards compatibility during migration. +/// Returns the bug_id (e.g. `"4"`). +#[allow(clippy::too_many_arguments)] +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]>, + depends_on: Option<&[u32]>, +) -> Result { + 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()); + } + + let bug_id = format!("{bug_number}"); + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str("type: bug\n"); + content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); + if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { + let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); + content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); + } + 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"); + } + + // Write to database content store and CRDT. + write_story_content(root, &bug_id, "1_backlog", &content); + + Ok(bug_id) +} + +/// Returns true if the item stem is a bug item. +/// +/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then +/// falls back to reading `type: bug` from the content store for numeric-only IDs. +pub(super) fn is_bug_item(stem: &str) -> bool { + let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); + if after_num.starts_with("_bug_") { + return true; + } + // Numeric-only ID: check content store front matter. + if after_num.is_empty() { + return crate::db::read_content(stem) + .and_then(|c| parse_front_matter(&c).ok()) + .and_then(|m| m.item_type) + .map(|t| t == "bug") + .unwrap_or(false); + } + false +} + +/// Extract bug name from content (heading or front matter). +pub(super) fn extract_bug_name_from_content(content: &str) -> Option { + // 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()); + } + } + None +} + +/// List all open bugs from CRDT + content store. +/// +/// Returns a sorted list of `(bug_id, name)` pairs. +pub fn list_bug_files(_root: &Path) -> Result, String> { + let mut bugs = Vec::new(); + + 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; + } + 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)); + } + + bugs.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(bugs) +} diff --git a/server/src/http/workflow/bug_ops/mod.rs b/server/src/http/workflow/bug_ops/mod.rs new file mode 100644 index 00000000..c9b87c99 --- /dev/null +++ b/server/src/http/workflow/bug_ops/mod.rs @@ -0,0 +1,12 @@ +//! Bug, spike, and refactor pipeline-item operations — creation and listing. + +mod bug; +mod refactor; +mod spike; + +#[cfg(test)] +mod tests; + +pub use bug::{create_bug_file, list_bug_files}; +pub use refactor::{create_refactor_file, list_refactor_files}; +pub use spike::create_spike_file; diff --git a/server/src/http/workflow/bug_ops/refactor.rs b/server/src/http/workflow/bug_ops/refactor.rs new file mode 100644 index 00000000..d07428cc --- /dev/null +++ b/server/src/http/workflow/bug_ops/refactor.rs @@ -0,0 +1,114 @@ +//! Refactor-item creation and listing operations. + +use crate::io::story_metadata::parse_front_matter; +use std::path::Path; + +use super::super::{next_item_number, slugify_name, write_story_content}; + +/// Create a refactor work item and store it in the database. +/// +/// Returns the refactor_id (e.g. `"5"`). +pub fn create_refactor_file( + root: &Path, + name: &str, + description: Option<&str>, + acceptance_criteria: Option<&[String]>, + depends_on: Option<&[u32]>, +) -> Result { + 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()); + } + + let refactor_id = format!("{refactor_number}"); + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str("type: refactor\n"); + content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); + if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { + let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); + content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); + } + 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"); + + // Write to database content store and CRDT. + write_story_content(root, &refactor_id, "1_backlog", &content); + + Ok(refactor_id) +} + +/// Returns true if the item stem is a refactor item. +/// +/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`), then +/// falls back to reading `type: refactor` from the content store for numeric-only IDs. +pub(super) fn is_refactor_item(stem: &str) -> bool { + let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); + if after_num.starts_with("_refactor_") { + return true; + } + // Numeric-only ID: check content store front matter. + if after_num.is_empty() { + return crate::db::read_content(stem) + .and_then(|c| parse_front_matter(&c).ok()) + .and_then(|m| m.item_type) + .map(|t| t == "refactor") + .unwrap_or(false); + } + false +} + +/// List all open refactors from CRDT + content store. +/// +/// Returns a sorted list of `(refactor_id, name)` pairs. +pub fn list_refactor_files(_root: &Path) -> Result, String> { + let mut refactors = Vec::new(); + + 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; + } + 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)); + } + + refactors.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(refactors) +} diff --git a/server/src/http/workflow/bug_ops/spike.rs b/server/src/http/workflow/bug_ops/spike.rs new file mode 100644 index 00000000..90e929a3 --- /dev/null +++ b/server/src/http/workflow/bug_ops/spike.rs @@ -0,0 +1,62 @@ +//! Spike-item creation operations. + +use std::path::Path; + +use super::super::{next_item_number, slugify_name, write_story_content}; + +/// Create a spike file and store it in the database. +/// +/// Returns the spike_id (e.g. `"4"`). +pub fn create_spike_file( + root: &Path, + name: &str, + description: Option<&str>, + acceptance_criteria: &[String], +) -> Result { + 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()); + } + + let spike_id = format!("{spike_number}"); + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str("type: spike\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\n"); + content.push_str("## Acceptance Criteria\n\n"); + if acceptance_criteria.is_empty() { + content.push_str("- [ ] TBD\n"); + } else { + for criterion in acceptance_criteria { + content.push_str(&format!("- [ ] {criterion}\n")); + } + } + + // Write to database content store and CRDT. + write_story_content(root, &spike_id, "1_backlog", &content); + + Ok(spike_id) +} diff --git a/server/src/http/workflow/bug_ops/tests.rs b/server/src/http/workflow/bug_ops/tests.rs new file mode 100644 index 00000000..1750533c --- /dev/null +++ b/server/src/http/workflow/bug_ops/tests.rs @@ -0,0 +1,487 @@ +//! Tests for bug, spike, and refactor pipeline-item operations. + +use super::bug::{create_bug_file, extract_bug_name_from_content, list_bug_files}; +use super::refactor::{create_refactor_file, list_refactor_files}; +use super::spike::create_spike_file; +use crate::io::story_metadata::parse_front_matter; +use std::fs; + +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(); + assert!(super::super::next_item_number(tmp.path()).unwrap() >= 1); +} + +#[test] +fn next_item_number_increments_from_existing_bugs() { + crate::db::ensure_content_store(); + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".huskies/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write(backlog.join("1_bug_crash.md"), "").unwrap(); + fs::write(backlog.join("3_bug_another.md"), "").unwrap(); + // Also write to content store so next_item_number sees them. + crate::db::write_item_with_content("1_bug_crash", "1_backlog", "---\nname: Crash\n---\n"); + crate::db::write_item_with_content("3_bug_another", "1_backlog", "---\nname: Another\n---\n"); + assert!(super::super::next_item_number(tmp.path()).unwrap() >= 4); +} + +#[test] +fn next_item_number_scans_archived_too() { + crate::db::ensure_content_store(); + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".huskies/work/1_backlog"); + let archived = tmp.path().join(".huskies/work/5_done"); + fs::create_dir_all(&backlog).unwrap(); + fs::create_dir_all(&archived).unwrap(); + fs::write(archived.join("5_bug_old.md"), "").unwrap(); + // Also write to content store so next_item_number sees it. + crate::db::write_item_with_content("5_bug_old", "5_done", "---\nname: Old Bug\n---\n"); + assert!(super::super::next_item_number(tmp.path()).unwrap() >= 6); +} + +#[test] +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. + let tmp = tempfile::tempdir().unwrap(); + let result = list_bug_files(tmp.path()); + assert!(result.is_ok()); +} + +#[test] +fn list_bug_files_excludes_archive_subdir() { + let tmp = tempfile::tempdir().unwrap(); + crate::db::ensure_content_store(); + // Bug in backlog (should appear). + crate::db::write_item_with_content( + "7001_bug_open", + "1_backlog", + "---\nname: 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", + "---\nname: Closed Bug\n---\n# Bug 7002: Closed Bug\n", + ); + + let result = list_bug_files(tmp.path()).unwrap(); + assert!( + result + .iter() + .any(|(id, name)| id == "7001_bug_open" && name == "Open Bug") + ); + assert!(!result.iter().any(|(id, _)| id == "7002_bug_closed")); +} + +#[test] +fn list_bug_files_sorted_by_id() { + let tmp = tempfile::tempdir().unwrap(); + crate::db::ensure_content_store(); + crate::db::write_item_with_content( + "7013_bug_third", + "1_backlog", + "---\nname: Third\n---\n# Bug 7013: Third\n", + ); + crate::db::write_item_with_content( + "7011_bug_first", + "1_backlog", + "---\nname: First\n---\n# Bug 7011: First\n", + ); + crate::db::write_item_with_content( + "7012_bug_second", + "1_backlog", + "---\nname: Second\n---\n# Bug 7012: Second\n", + ); + + let result = list_bug_files(tmp.path()).unwrap(); + // 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); +} + +#[test] +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(); + 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\n2. Click submit", + "Page crashes with 500 error", + "Login succeeds", + Some(&["Login form submits without error".to_string()]), + None, + ) + .unwrap(); + + assert!( + bug_id.chars().all(|c| c.is_ascii_digit()), + "bug ID must be numeric-only, 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"); + + assert!( + contents.starts_with("---\ntype: bug\nname: \"Login Crash\"\n---"), + "bug file must start with YAML front matter including type field" + ); + assert!( + contents.contains("Login Crash"), + "content should mention bug name" + ); + 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, + 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()); + + let bug_id = create_bug_file( + tmp.path(), + "Some Bug", + "desc", + "steps", + "actual", + "expected", + None, + None, + ) + .unwrap(); + + let contents = crate::db::read_content(&bug_id).expect("bug content should exist"); + + assert!( + contents.starts_with("---\ntype: bug\nname: \"Some Bug\"\n---"), + "bug file must have YAML front matter with type field" + ); + 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(); + + assert!( + spike_id.chars().all(|c| c.is_ascii_digit()), + "spike ID must be numeric-only, got: {spike_id}" + ); + + let contents = crate::db::read_content(&spike_id).expect("spike content should exist"); + + assert!( + contents.starts_with("---\ntype: spike\nname: \"Filesystem Watcher Architecture\"\n---"), + "spike file must start with YAML front matter including type field" + ); + assert!( + contents.contains("Filesystem Watcher Architecture"), + "content should mention spike name" + ); + 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?"; + + let spike_id = + create_spike_file(tmp.path(), "FS Watcher Spike", Some(description), &[]).unwrap(); + + 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"); + assert!(contents.contains(description)); +} + +#[test] +fn create_spike_file_uses_placeholder_when_no_description() { + let tmp = tempfile::tempdir().unwrap(); + let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap(); + + 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"); + 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(); + 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"); + + 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(); + 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", + "---\nname: Existing\n---\n", + ); + + let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap(); + assert!( + spike_id.chars().all(|c| c.is_ascii_digit()), + "spike ID must be numeric-only, got: {spike_id}" + ); + let num: u32 = spike_id.parse().unwrap(); + assert!( + num >= 7051, + "expected spike number >= 7051, got: {spike_id}" + ); +} + +// ── Bug 640: create_bug_file / create_refactor_file depends_on tests ──────── + +#[test] +fn create_bug_file_with_depends_on_writes_front_matter_array() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + + let bug_id = create_bug_file( + tmp.path(), + "Dep Bug", + "desc", + "steps", + "actual", + "expected", + None, + Some(&[42, 43]), + ) + .unwrap(); + + 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"); + + assert!( + contents.contains("depends_on: [42, 43]"), + "front matter should contain depends_on array: {contents}" + ); + assert!( + !contents.contains("depends_on: \"["), + "depends_on must not be quoted string: {contents}" + ); + + let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); + assert_eq!(meta.depends_on, Some(vec![42, 43])); +} + +#[test] +fn create_bug_file_without_depends_on_omits_field() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + + let bug_id = create_bug_file( + tmp.path(), + "No Dep Bug", + "desc", + "steps", + "actual", + "expected", + None, + None, + ) + .unwrap(); + + 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"); + + assert!( + !contents.contains("depends_on"), + "front matter must not contain depends_on when not provided: {contents}" + ); +} + +#[test] +fn create_refactor_file_with_depends_on_writes_front_matter_array() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + + let refactor_id = + create_refactor_file(tmp.path(), "Dep Refactor", None, None, Some(&[99])).unwrap(); + + let contents = crate::db::read_content(&refactor_id) + .or_else(|| { + let filepath = tmp + .path() + .join(format!(".huskies/work/1_backlog/{refactor_id}.md")); + fs::read_to_string(filepath).ok() + }) + .expect("refactor content should exist"); + + assert!( + contents.contains("depends_on: [99]"), + "front matter should contain depends_on array: {contents}" + ); + + let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); + assert_eq!(meta.depends_on, Some(vec![99])); +} + +#[test] +fn create_refactor_file_without_depends_on_omits_field() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo(tmp.path()); + + let refactor_id = + create_refactor_file(tmp.path(), "No Dep Refactor", None, None, None).unwrap(); + + let contents = crate::db::read_content(&refactor_id) + .or_else(|| { + let filepath = tmp + .path() + .join(format!(".huskies/work/1_backlog/{refactor_id}.md")); + fs::read_to_string(filepath).ok() + }) + .expect("refactor content should exist"); + + assert!( + !contents.contains("depends_on"), + "front matter must not contain depends_on when not provided: {contents}" + ); +} + +#[test] +fn list_refactor_files_returns_ok() { + let tmp = tempfile::tempdir().unwrap(); + let result = list_refactor_files(tmp.path()); + assert!(result.is_ok()); +}