use serde::Deserialize; use std::fs; use std::path::Path; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct StoryMetadata { pub name: Option, pub coverage_baseline: Option, pub merge_failure: Option, pub agent: Option, pub review_hold: Option, pub manual_qa: 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, coverage_baseline: Option, merge_failure: Option, agent: Option, review_hold: Option, manual_qa: 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 { StoryMetadata { name: front.name, coverage_baseline: front.coverage_baseline, merge_failure: front.merge_failure, agent: front.agent, review_hold: front.review_hold, manual_qa: front.manual_qa, } } /// Write or update a `coverage_baseline:` field in the YAML front matter of a story file. /// /// If front matter is present, adds or replaces `coverage_baseline:` before the closing `---`. /// If no front matter is present, this is a no-op (returns Ok). pub fn write_coverage_baseline(path: &Path, coverage_pct: f64) -> Result<(), String> { let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; let updated = set_front_matter_field(&contents, "coverage_baseline", &format!("{coverage_pct:.1}%")); fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; Ok(()) } /// Write or update a `merge_failure:` field in the YAML front matter of a story file. /// /// The reason is stored as a quoted YAML string so that colons, hashes, and newlines /// in the failure message do not break front-matter parsing. /// If no front matter is present, this is a no-op (returns Ok). pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> { let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; // Produce a YAML-safe inline quoted string: collapse newlines, escape inner quotes. let escaped = reason.replace('"', "\\\"").replace('\n', " ").replace('\r', ""); let yaml_value = format!("\"{escaped}\""); let updated = set_front_matter_field(&contents, "merge_failure", &yaml_value); fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; Ok(()) } /// Write `review_hold: true` to the YAML front matter of a story file. /// /// Used to mark spikes that have passed QA and are waiting for human review. pub fn write_review_hold(path: &Path) -> Result<(), String> { let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; let updated = set_front_matter_field(&contents, "review_hold", "true"); fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; Ok(()) } /// Remove a key from the YAML front matter of a story file on disk. /// /// If front matter is present and contains the key, the line is removed. /// If no front matter or key is not found, the file is left unchanged. pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> { let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; let updated = remove_front_matter_field(&contents, key); if updated != contents { fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; } Ok(()) } /// Remove a key: value line from the YAML front matter of a markdown string. /// /// If no front matter (opening `---`) is found or the key is absent, returns content unchanged. fn remove_front_matter_field(contents: &str, key: &str) -> String { let mut lines: Vec = contents.lines().map(String::from).collect(); if lines.is_empty() || lines[0].trim() != "---" { return contents.to_string(); } let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") { Some(i) => i + 1, None => return contents.to_string(), }; let key_prefix = format!("{key}:"); if let Some(idx) = lines[1..close_idx] .iter() .position(|l| l.trim_start().starts_with(&key_prefix)) .map(|i| i + 1) { lines.remove(idx); } else { return contents.to_string(); } let mut result = lines.join("\n"); if contents.ends_with('\n') { result.push('\n'); } result } /// Insert or update a key: value pair in the YAML front matter of a markdown string. /// /// If no front matter (opening `---`) is found, returns the content unchanged. pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String { let mut lines: Vec = contents.lines().map(String::from).collect(); if lines.is_empty() || lines[0].trim() != "---" { return contents.to_string(); } // Find closing --- (search from index 1) let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") { Some(i) => i + 1, None => return contents.to_string(), }; let key_prefix = format!("{key}:"); let existing_idx = lines[1..close_idx] .iter() .position(|l| l.trim_start().starts_with(&key_prefix)) .map(|i| i + 1); let new_line = format!("{key}: {value}"); if let Some(idx) = existing_idx { lines[idx] = new_line; } else { lines.insert(close_idx, new_line); } let mut result = lines.join("\n"); if contents.ends_with('\n') { result.push('\n'); } result } /// Append rejection notes to a story file body. /// /// Adds a `## QA Rejection Notes` section at the end of the file so the coder /// agent can see what needs fixing. pub fn write_rejection_notes(path: &Path, notes: &str) -> Result<(), String> { let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n"); let updated = format!("{contents}{section}"); fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; Ok(()) } /// Check whether a story requires manual QA (defaults to false). pub fn requires_manual_qa(path: &Path) -> bool { let contents = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return false, }; match parse_front_matter(&contents) { Ok(meta) => meta.manual_qa.unwrap_or(false), Err(_) => false, } } 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() } #[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 set_front_matter_field_inserts_new_key() { let input = "---\nname: My Story\n---\n# Body\n"; let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); assert!(output.contains("coverage_baseline: 55.0%")); assert!(output.contains("name: My Story")); assert!(output.ends_with('\n')); } #[test] fn set_front_matter_field_updates_existing_key() { let input = "---\nname: My Story\ncoverage_baseline: 40.0%\n---\n# Body\n"; let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); assert!(output.contains("coverage_baseline: 55.0%")); assert!(!output.contains("40.0%")); } #[test] fn set_front_matter_field_no_op_without_front_matter() { let input = "# No front matter\n"; let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); assert_eq!(output, input); } #[test] fn write_coverage_baseline_updates_file() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); write_coverage_baseline(&path, 82.3).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(contents.contains("coverage_baseline: 82.3%")); } #[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 remove_front_matter_field_removes_key() { let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n"; let output = remove_front_matter_field(input, "merge_failure"); assert!(!output.contains("merge_failure")); assert!(output.contains("name: My Story")); assert!(output.ends_with('\n')); } #[test] fn remove_front_matter_field_no_op_when_absent() { let input = "---\nname: My Story\n---\n# Body\n"; let output = remove_front_matter_field(input, "merge_failure"); assert_eq!(output, input); } #[test] fn remove_front_matter_field_no_op_without_front_matter() { let input = "# No front matter\n"; let output = remove_front_matter_field(input, "merge_failure"); assert_eq!(output, input); } #[test] fn clear_front_matter_field_updates_file() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n").unwrap(); clear_front_matter_field(&path, "merge_failure").unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(!contents.contains("merge_failure")); assert!(contents.contains("name: Test")); } #[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 write_review_hold_sets_field() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("spike.md"); std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap(); write_review_hold(&path).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(contents.contains("review_hold: true")); assert!(contents.contains("name: My Spike")); } #[test] fn parses_manual_qa_from_front_matter() { let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.manual_qa, Some(false)); } #[test] fn manual_qa_defaults_to_none() { let input = "---\nname: Story\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.manual_qa, None); } #[test] fn requires_manual_qa_defaults_false() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); assert!(!requires_manual_qa(&path)); } #[test] fn requires_manual_qa_true_when_set() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\nmanual_qa: true\n---\n# Story\n").unwrap(); assert!(requires_manual_qa(&path)); } #[test] fn requires_manual_qa_false_when_set() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\nmanual_qa: false\n---\n# Story\n").unwrap(); assert!(!requires_manual_qa(&path)); } #[test] fn write_rejection_notes_appends_section() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); write_rejection_notes(&path, "Button color is wrong").unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(contents.contains("## QA Rejection Notes")); assert!(contents.contains("Button color is wrong")); } }