287 lines
11 KiB
Rust
287 lines
11 KiB
Rust
|
|
//! 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<String> = 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<String> = 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<String> = 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<String> = 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}");
|
||
|
|
}
|
||
|
|
}
|