huskies: merge 796
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
//! 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 `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(())
|
||||
}
|
||||
|
||||
/// 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 `blocked: true` to story content (pure function).
|
||||
pub fn write_blocked_in_content(contents: &str) -> String {
|
||||
set_front_matter_field(contents, "blocked", "true")
|
||||
}
|
||||
|
||||
/// 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user