Files
huskies/server/src/agents/pool/auto_assign/story_checks.rs
T

175 lines
6.3 KiB
Rust
Raw Normal View History

//! Front-matter checks for story files: review holds, blocked state, and merge failures.
use std::path::Path;
/// Read the optional `agent:` field from the front matter of a story file.
///
/// Returns `Some(agent_name)` if the front matter specifies an agent, or `None`
/// if the field is absent or the file cannot be read / parsed.
pub(super) fn read_story_front_matter_agent(
project_root: &Path,
stage_dir: &str,
story_id: &str,
) -> Option<String> {
use crate::io::story_metadata::parse_front_matter;
let path = project_root
.join(".huskies")
.join("work")
.join(stage_dir)
.join(format!("{story_id}.md"));
let contents = std::fs::read_to_string(path).ok()?;
parse_front_matter(&contents).ok()?.agent
}
/// Return `true` if the story file in the given stage has `review_hold: true` in its front matter.
pub(super) fn has_review_hold(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter;
let path = project_root
.join(".huskies")
.join("work")
.join(stage_dir)
.join(format!("{story_id}.md"));
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.review_hold)
.unwrap_or(false)
}
/// Return `true` if the story file has `blocked: true` in its front matter.
pub(super) fn is_story_blocked(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter;
let path = project_root
.join(".huskies")
.join("work")
.join(stage_dir)
.join(format!("{story_id}.md"));
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.blocked)
.unwrap_or(false)
}
/// Return `true` if the story has any `depends_on` entries that are not yet in
/// `5_done` or `6_archived`.
///
/// Reads dependency state from the CRDT document first. Falls back to the
/// filesystem when the CRDT layer is not initialised.
pub(super) fn has_unmet_dependencies(
project_root: &Path,
stage_dir: &str,
story_id: &str,
) -> bool {
// Prefer CRDT-based check.
let crdt_deps = crate::crdt_state::check_unmet_deps_crdt(story_id);
if !crdt_deps.is_empty() {
return true;
}
// If the CRDT had the item and returned empty deps, it means all are met.
if crate::crdt_state::read_item(story_id).is_some() {
return false;
}
// Fallback: filesystem check (CRDT not initialised or item not yet in CRDT).
!crate::io::story_metadata::check_unmet_deps(project_root, stage_dir, story_id).is_empty()
}
/// Return `true` if the story file has a `merge_failure` field in its front matter.
pub(super) fn has_merge_failure(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter;
let path = project_root
.join(".huskies")
.join("work")
.join(stage_dir)
.join(format!("{story_id}.md"));
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.merge_failure)
.is_some()
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn has_review_hold_returns_true_when_set() {
let tmp = tempfile::tempdir().unwrap();
let qa_dir = tmp.path().join(".huskies/work/3_qa");
std::fs::create_dir_all(&qa_dir).unwrap();
let spike_path = qa_dir.join("10_spike_research.md");
std::fs::write(
&spike_path,
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
)
.unwrap();
assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
}
#[test]
fn has_review_hold_returns_false_when_not_set() {
let tmp = tempfile::tempdir().unwrap();
let qa_dir = tmp.path().join(".huskies/work/3_qa");
std::fs::create_dir_all(&qa_dir).unwrap();
let spike_path = qa_dir.join("10_spike_research.md");
std::fs::write(&spike_path, "---\nname: Research spike\n---\n# Spike\n").unwrap();
assert!(!has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
}
#[test]
fn has_review_hold_returns_false_when_file_missing() {
let tmp = tempfile::tempdir().unwrap();
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
}
#[test]
fn has_unmet_dependencies_returns_true_when_dep_not_done() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".huskies/work/2_current");
std::fs::create_dir_all(&current).unwrap();
std::fs::write(
current.join("10_story_blocked.md"),
"---\nname: Blocked\ndepends_on: [999]\n---\n",
)
.unwrap();
assert!(has_unmet_dependencies(tmp.path(), "2_current", "10_story_blocked"));
}
#[test]
fn has_unmet_dependencies_returns_false_when_dep_done() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".huskies/work/2_current");
let done = tmp.path().join(".huskies/work/5_done");
std::fs::create_dir_all(&current).unwrap();
std::fs::create_dir_all(&done).unwrap();
std::fs::write(done.join("999_story_dep.md"), "---\nname: Dep\n---\n").unwrap();
std::fs::write(
current.join("10_story_ok.md"),
"---\nname: Ok\ndepends_on: [999]\n---\n",
)
.unwrap();
assert!(!has_unmet_dependencies(tmp.path(), "2_current", "10_story_ok"));
}
#[test]
fn has_unmet_dependencies_returns_false_when_no_deps() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".huskies/work/2_current");
std::fs::create_dir_all(&current).unwrap();
std::fs::write(current.join("5_story_free.md"), "---\nname: Free\n---\n").unwrap();
assert!(!has_unmet_dependencies(tmp.path(), "2_current", "5_story_free"));
}
}