use serde::Deserialize; use std::fs; use std::path::Path; /// QA mode for a story: determines how the pipeline handles post-coder review. /// /// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests). /// If gates pass, advance straight to merge. /// - `Agent` — spin up a QA agent (Claude session) to review code and run gates. /// - `Human` — hold in QA for human approval after server gates pass. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QaMode { Server, Agent, Human, } impl QaMode { /// Parse a string into a `QaMode`. Returns `None` for unrecognised values. pub fn from_str(s: &str) -> Option { match s.trim().to_lowercase().as_str() { "server" => Some(Self::Server), "agent" => Some(Self::Agent), "human" => Some(Self::Human), _ => None, } } pub fn as_str(&self) -> &'static str { match self { Self::Server => "server", Self::Agent => "agent", Self::Human => "human", } } } impl std::fmt::Display for QaMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } #[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 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, } #[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, /// New configurable QA mode field: "human", "server", or "agent". qa: Option, /// Legacy boolean field — mapped to `qa: human` (true) or ignored (false/absent). manual_qa: Option, /// Number of times this story has been retried at its current pipeline stage. retry_count: Option, /// When `true`, auto-assign will skip this story (retry limit exceeded). blocked: 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 { // Resolve qa mode: prefer the new `qa` field, fall back to legacy `manual_qa`. let qa = if let Some(ref qa_str) = front.qa { QaMode::from_str(qa_str) } else { front.manual_qa.and_then(|v| if v { Some(QaMode::Human) } else { None }) }; 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, } } /// 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 } /// Increment the `retry_count` field in the story file's front matter. /// /// Reads the current value (defaulting to 0), increments by 1, and writes back. /// Returns the new retry count. pub fn increment_retry_count(path: &Path) -> Result { let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; let current = parse_front_matter(&contents) .ok() .and_then(|m| m.retry_count) .unwrap_or(0); let new_count = current + 1; let updated = set_front_matter_field(&contents, "retry_count", &new_count.to_string()); fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; Ok(new_count) } /// Write `blocked: true` to the YAML front matter of a story file. /// /// Used to mark stories that have exceeded the retry limit and should not /// be auto-assigned again. pub fn write_blocked(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, "blocked", "true"); fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; Ok(()) } /// 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(()) } /// 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, } } 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_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 legacy_manual_qa_true_maps_to_human() { let input = "---\nname: Story\nmanual_qa: true\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Human)); } #[test] fn legacy_manual_qa_false_maps_to_none() { let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, None); } #[test] fn qa_field_takes_precedence_over_manual_qa() { let input = "---\nname: Story\nqa: server\nmanual_qa: true\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Server)); } #[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 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")); } }