//! 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) }