//! Parsing logic for story YAML front matter and todo checkboxes. use serde::Deserialize; use std::fs; use std::path::Path; use super::types::{QaMode, StoryMetaError, StoryMetadata}; #[derive(Debug, Deserialize)] pub(super) struct FrontMatter { pub name: Option, pub coverage_baseline: Option, pub merge_failure: Option, pub agent: Option, pub review_hold: Option, /// Configurable QA mode field: "human", "server", or "agent". pub qa: Option, /// Number of times this story has been retried at its current pipeline stage. pub retry_count: Option, /// When `true`, auto-assign will skip this story (retry limit exceeded). pub blocked: Option, /// Story numbers this story depends on. pub depends_on: Option>, /// When `true`, the story is frozen. pub frozen: Option, /// Set to `true` when an agent's `run_tests` call returns `passed=true`. /// Used by the bug-645 salvage path to distinguish a genuine test-passing /// session from one that merely compiled. pub run_tests_passed: Option, /// Item type: "story", "bug", "spike", or "refactor". #[serde(rename = "type")] pub item_type: Option, /// Set to `true` when the auto-assigner has already spawned a mergemaster /// session for a content-conflict failure. pub mergemaster_attempted: Option, /// Epic this item belongs to (numeric ID as string, e.g. "880"). pub epic: Option, } /// Parse the YAML front matter block from a story markdown string. 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 qa = front.qa.as_deref().and_then(QaMode::from_str); StoryMetadata { name: front.name, coverage_baseline: front.coverage_baseline, merge_failure: front.merge_failure, agent: front.agent, review_hold: front.review_hold, qa, retry_count: front.retry_count, blocked: front.blocked, depends_on: front.depends_on, frozen: front.frozen, run_tests_passed: front.run_tests_passed, item_type: front.item_type, mergemaster_attempted: front.mergemaster_attempted, epic: front.epic, } } /// Parse unchecked todo items (`- [ ] ...`) from a markdown string. 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() } /// Resolve the effective QA mode for a story file. /// /// Reads the `qa` front matter field. If absent, falls back to `default`. /// Spikes are **not** handled here — the caller is responsible for overriding /// to `Human` for spikes. pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode { let contents = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return default, }; match parse_front_matter(&contents) { Ok(meta) => meta.qa.unwrap_or(default), Err(_) => default, } } /// Resolve the effective QA mode for a story by story ID. /// /// Checks the typed `qa_mode` CRDT register first. If the register holds a /// recognised value (`"server"`, `"agent"`, or `"human"`), returns it. /// Otherwise falls back to parsing the `qa` YAML front-matter field from /// `contents`. If neither source provides a value, returns `default`. pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaMode) -> QaMode { // CRDT register takes precedence over YAML front matter. if let Some(view) = crate::crdt_state::read_item(story_id) && let Some(ref s) = view.qa_mode && let Some(mode) = QaMode::from_str(s) { return mode; } // Fall back to YAML front matter for backward compatibility. match parse_front_matter(contents) { Ok(meta) => meta.qa.unwrap_or(default), Err(_) => default, } } /// Return `true` if the story has `frozen: true` in the content store. /// /// Used by the pipeline advance code to suppress stage transitions for frozen stories. pub fn is_story_frozen_in_store(story_id: &str) -> bool { let contents = match crate::db::read_content(story_id) { Some(c) => c, None => return false, }; parse_front_matter(&contents) .ok() .and_then(|m| m.frozen) .unwrap_or(false) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_front_matter_metadata() { let input = r#"--- name: Establish the TDD Workflow and Gates workflow: tdd --- # Story 26 "#; let meta = parse_front_matter(input).expect("front matter"); assert_eq!( meta.name.as_deref(), Some("Establish the TDD Workflow and Gates") ); assert_eq!(meta.coverage_baseline, None); } #[test] fn parses_coverage_baseline_from_front_matter() { let input = "---\nname: Test Story\ncoverage_baseline: 78.5%\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.coverage_baseline.as_deref(), Some("78.5%")); } #[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"]); } #[test] fn parses_review_hold_from_front_matter() { let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.review_hold, Some(true)); } #[test] fn review_hold_defaults_to_none() { let input = "---\nname: Story\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.review_hold, None); } #[test] fn parses_qa_mode_from_front_matter() { let input = "---\nname: Story\nqa: server\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Server)); let input = "---\nname: Story\nqa: agent\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Agent)); let input = "---\nname: Story\nqa: human\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Human)); } #[test] fn qa_mode_defaults_to_none() { let input = "---\nname: Story\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, None); } #[test] fn resolve_qa_mode_uses_file_value() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap(); assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human); } #[test] fn resolve_qa_mode_falls_back_to_default() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server); assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent); } #[test] fn resolve_qa_mode_missing_file_uses_default() { let path = std::path::Path::new("/nonexistent/story.md"); assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server); } #[test] fn parses_depends_on_from_front_matter() { let input = "---\nname: Story\ndepends_on: [477, 478]\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.depends_on, Some(vec![477, 478])); } #[test] fn depends_on_defaults_to_none() { let input = "---\nname: Story\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.depends_on, None); } }