//! Front-matter field manipulation: insert, update, remove, and write helpers. use std::fs; use std::path::Path; /// 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 } /// 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. pub(super) 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 } /// 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(()) } /// 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(()) } /// Write or update `depends_on:` field in the YAML front matter of a story file. /// /// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`. /// If `deps` is empty the field is removed. /// If no front matter is present, this is a no-op (returns Ok). pub fn write_depends_on(path: &Path, deps: &[u32]) -> Result<(), String> { let contents = fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; let updated = if deps.is_empty() { remove_front_matter_field(&contents, "depends_on") } else { let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); let yaml_value = format!("[{}]", nums.join(", ")); set_front_matter_field(&contents, "depends_on", &yaml_value) }; 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 markdown string (pure function). /// /// Returns the updated content. If no front matter or key is not found, /// returns the original content unchanged. pub fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String { remove_front_matter_field(contents, key) } /// Append rejection notes to a markdown string (pure function). /// /// Returns the updated content with a `## QA Rejection Notes` section appended. pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String { let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n"); format!("{contents}{section}") } /// Write or update `merge_failure` in story content (pure function). pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String { let escaped = reason .replace('"', "\\\"") .replace('\n', " ") .replace('\r', ""); let yaml_value = format!("\"{escaped}\""); set_front_matter_field(contents, "merge_failure", &yaml_value) } /// Write `review_hold: true` to story content (pure function). pub fn write_review_hold_in_content(contents: &str) -> String { set_front_matter_field(contents, "review_hold", "true") } /// Write `mergemaster_attempted: true` to story content (pure function). /// /// Used by the auto-assigner to record that a mergemaster session has been /// spawned for a content-conflict failure, preventing repeated auto-spawns. pub fn write_mergemaster_attempted_in_content(contents: &str) -> String { set_front_matter_field(contents, "mergemaster_attempted", "true") } /// Write or update `depends_on` in story content (pure function). /// /// Serialises `deps` as an inline YAML sequence, e.g. `[477, 478]`. /// If `deps` is empty the field is removed. pub fn write_depends_on_in_content(contents: &str, deps: &[u32]) -> String { if deps.is_empty() { remove_front_matter_field(contents, "depends_on") } else { let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); let yaml_value = format!("[{}]", nums.join(", ")); set_front_matter_field(contents, "depends_on", &yaml_value) } } #[cfg(test)] mod tests { use super::*; #[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 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 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 write_depends_on_sets_field() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); write_depends_on(&path, &[477, 478]).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(contents.contains("depends_on: [477, 478]"), "{contents}"); } #[test] fn write_depends_on_removes_field_when_empty() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\ndepends_on: [477]\n---\n# Story\n").unwrap(); write_depends_on(&path, &[]).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); assert!(!contents.contains("depends_on"), "{contents}"); } }