use serde::Deserialize; #[derive(Debug, Clone, PartialEq, Eq)] pub enum TestPlanStatus { Approved, WaitingForApproval, Unknown(String), } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct StoryMetadata { pub name: Option, pub test_plan: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum StoryMetaError { MissingFrontMatter, InvalidFrontMatter(String), } impl std::fmt::Display for StoryMetaError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"), StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"), } } } #[derive(Debug, Deserialize)] struct FrontMatter { name: Option, test_plan: Option, } pub fn parse_front_matter(contents: &str) -> Result { let mut lines = contents.lines(); let first = lines.next().unwrap_or_default().trim(); if first != "---" { return Err(StoryMetaError::MissingFrontMatter); } let mut front_lines = Vec::new(); for line in &mut lines { let trimmed = line.trim(); if trimmed == "---" { let raw = front_lines.join("\n"); let front: FrontMatter = serde_yaml::from_str(&raw) .map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?; return Ok(build_metadata(front)); } front_lines.push(line); } Err(StoryMetaError::InvalidFrontMatter( "Missing closing front matter delimiter".to_string(), )) } fn build_metadata(front: FrontMatter) -> StoryMetadata { let test_plan = front.test_plan.as_deref().map(parse_test_plan_status); StoryMetadata { name: front.name, test_plan, } } pub fn parse_unchecked_todos(contents: &str) -> Vec { contents .lines() .filter_map(|line| { let trimmed = line.trim(); trimmed .strip_prefix("- [ ] ") .map(|text| text.to_string()) }) .collect() } fn parse_test_plan_status(value: &str) -> TestPlanStatus { match value { "approved" => TestPlanStatus::Approved, "waiting_for_approval" => TestPlanStatus::WaitingForApproval, other => TestPlanStatus::Unknown(other.to_string()), } } #[cfg(test)] mod tests { use super::*; #[test] fn parses_front_matter_metadata() { let input = r#"--- name: Establish the TDD Workflow and Gates test_plan: approved workflow: tdd --- # Story 26 "#; let meta = parse_front_matter(input).expect("front matter"); assert_eq!( meta, StoryMetadata { name: Some("Establish the TDD Workflow and Gates".to_string()), test_plan: Some(TestPlanStatus::Approved), } ); } #[test] fn rejects_missing_front_matter() { let input = "# Story 26\n"; assert_eq!( parse_front_matter(input), Err(StoryMetaError::MissingFrontMatter) ); } #[test] fn rejects_unclosed_front_matter() { let input = "---\nname: Test\n"; assert!(matches!( parse_front_matter(input), Err(StoryMetaError::InvalidFrontMatter(_)) )); } #[test] fn parse_unchecked_todos_mixed() { let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n"; assert_eq!( parse_unchecked_todos(input), vec!["First thing", "Second thing"] ); } #[test] fn parse_unchecked_todos_all_checked() { let input = "- [x] Done\n- [x] Also done\n"; assert!(parse_unchecked_todos(input).is_empty()); } #[test] fn parse_unchecked_todos_no_checkboxes() { let input = "# Story\nSome text\n- A bullet\n"; assert!(parse_unchecked_todos(input).is_empty()); } #[test] fn parse_unchecked_todos_leading_whitespace() { let input = " - [ ] Indented item\n"; assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]); } }