2026-02-19 12:54:04 +00:00
|
|
|
use serde::Deserialize;
|
2026-02-25 09:50:31 +00:00
|
|
|
use std::fs;
|
|
|
|
|
use std::path::Path;
|
2026-02-19 12:54:04 +00:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
|
|
|
pub struct StoryMetadata {
|
|
|
|
|
pub name: Option<String>,
|
2026-02-25 09:50:31 +00:00
|
|
|
pub coverage_baseline: Option<String>,
|
2026-02-27 10:23:30 +00:00
|
|
|
pub merge_failure: Option<String>,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
pub enum StoryMetaError {
|
|
|
|
|
MissingFrontMatter,
|
|
|
|
|
InvalidFrontMatter(String),
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 18:05:21 +00:00
|
|
|
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}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
#[derive(Debug, Deserialize)]
|
|
|
|
|
struct FrontMatter {
|
|
|
|
|
name: Option<String>,
|
2026-02-25 09:50:31 +00:00
|
|
|
coverage_baseline: Option<String>,
|
2026-02-27 10:23:30 +00:00
|
|
|
merge_failure: Option<String>,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
|
|
|
|
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,
|
2026-02-25 09:50:31 +00:00
|
|
|
coverage_baseline: front.coverage_baseline,
|
2026-02-27 10:23:30 +00:00
|
|
|
merge_failure: front.merge_failure,
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 09:50:31 +00:00
|
|
|
/// 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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 10:23:30 +00:00
|
|
|
/// 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(())
|
|
|
|
|
}
|
|
|
|
|
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
/// 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<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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 09:50:31 +00:00
|
|
|
/// 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.
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:33:45 +00:00
|
|
|
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
|
|
|
|
contents
|
|
|
|
|
.lines()
|
|
|
|
|
.filter_map(|line| {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
trimmed
|
|
|
|
|
.strip_prefix("- [ ] ")
|
|
|
|
|
.map(|text| text.to_string())
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:54:04 +00:00
|
|
|
#[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");
|
2026-02-25 09:50:31 +00:00
|
|
|
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%"));
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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(_))
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-02-19 15:33:45 +00:00
|
|
|
|
Refactor agents.rs (7631 lines) into agents/ module directory
Split the monolithic agents.rs into 6 focused modules:
- mod.rs: shared types (AgentEvent, AgentStatus, etc.) and re-exports
- pool.rs: AgentPool struct, all methods, and helper free functions
- pty.rs: PTY streaming (run_agent_pty_blocking, emit_event)
- lifecycle.rs: story movement functions (move_story_to_qa, etc.)
- gates.rs: acceptance gates (clippy, tests, coverage)
- merge.rs: squash-merge, conflict resolution, quality gates
All 121 original tests are preserved and distributed across modules.
Also adds clear_front_matter_field to story_metadata.rs to strip
stale merge_failure from front matter when stories move to done.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 23:06:14 +00:00
|
|
|
#[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"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 15:33:45 +00:00
|
|
|
#[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"]);
|
|
|
|
|
}
|
2026-02-19 12:54:04 +00:00
|
|
|
}
|