//! Front-matter checks for story files: review holds, blocked state, and merge failures. use std::path::Path; /// Read story contents from the DB content store (CRDT-backed). fn read_story_contents(_project_root: &Path, story_id: &str) -> Option { crate::db::read_content(story_id) } /// 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 { use crate::io::story_metadata::parse_front_matter; let contents = read_story_contents(project_root, story_id)?; 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 contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => 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 contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => 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::pipeline_state::read_typed(story_id) .ok() .flatten() .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 the list of dependency story numbers that are in `6_archived` (satisfied /// via archive rather than via a clean `5_done` completion). /// /// Used to emit a warning when backlog promotion fires because one or more deps were /// archived. Returns an empty `Vec` when no deps are archived. Reads from CRDT /// first; falls back to filesystem when CRDT is not initialised. pub(super) fn check_archived_dependencies( project_root: &Path, stage_dir: &str, story_id: &str, ) -> Vec { // Prefer CRDT-based check when the item is known to CRDT. if crate::pipeline_state::read_typed(story_id) .ok() .flatten() .is_some() { return crate::crdt_state::check_archived_deps_crdt(story_id); } // Fallback: filesystem. crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id) } /// Return `true` if the story file has `frozen: true` in its front matter. pub(super) fn is_story_frozen(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool { use crate::io::story_metadata::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, }; parse_front_matter(&contents) .ok() .and_then(|m| m.frozen) .unwrap_or(false) } /// 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 contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, }; parse_front_matter(&contents) .ok() .and_then(|m| m.merge_failure) .is_some() } /// Return `true` if the story's `merge_failure` contains a git content-conflict /// marker (`"Merge conflict"` or `"CONFLICT (content):"`). /// /// Used by the auto-assigner to decide whether to spawn mergemaster automatically. pub(super) fn has_content_conflict_failure( project_root: &Path, _stage_dir: &str, story_id: &str, ) -> bool { use crate::io::story_metadata::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, }; parse_front_matter(&contents) .ok() .and_then(|m| m.merge_failure) .map(|reason| reason.contains("Merge conflict") || reason.contains("CONFLICT (content):")) .unwrap_or(false) } /// Return `true` if the story has `mergemaster_attempted: true` in its front matter. /// /// Used to prevent the auto-assigner from repeatedly spawning mergemaster for /// the same story after a failed mergemaster session. pub(super) fn has_mergemaster_attempted( project_root: &Path, _stage_dir: &str, story_id: &str, ) -> bool { use crate::io::story_metadata::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, }; parse_front_matter(&contents) .ok() .and_then(|m| m.mergemaster_attempted) .unwrap_or(false) } // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn has_review_hold_returns_true_when_set() { let tmp = tempfile::tempdir().unwrap(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "10_spike_research", "3_qa", "---\nname: Research spike\nreview_hold: true\n---\n# Spike\n", ); 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(¤t).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(¤t).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(¤t).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" )); } // ── Bug 503: archived-dep visibility ───────────────────────────────────── /// check_archived_dependencies returns dep IDs that are in 6_archived. #[test] fn check_archived_dependencies_returns_archived_ids() { let tmp = tempfile::tempdir().unwrap(); let backlog = tmp.path().join(".huskies/work/1_backlog"); let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(&backlog).unwrap(); std::fs::create_dir_all(&archived).unwrap(); std::fs::write( archived.join("500_spike_crdt.md"), "---\nname: CRDT Spike\n---\n", ) .unwrap(); std::fs::write( backlog.join("503_story_dependent.md"), "---\nname: Dependent\ndepends_on: [500]\n---\n", ) .unwrap(); let archived_deps = check_archived_dependencies(tmp.path(), "1_backlog", "503_story_dependent"); assert_eq!(archived_deps, vec![500]); } /// check_archived_dependencies returns empty when dep is in 5_done (not archived). #[test] fn check_archived_dependencies_empty_when_dep_in_done() { let tmp = tempfile::tempdir().unwrap(); let backlog = tmp.path().join(".huskies/work/1_backlog"); let done = tmp.path().join(".huskies/work/5_done"); std::fs::create_dir_all(&backlog).unwrap(); std::fs::create_dir_all(&done).unwrap(); std::fs::write(done.join("490_story_done.md"), "---\nname: Done\n---\n").unwrap(); std::fs::write( backlog.join("503_story_waiting.md"), "---\nname: Waiting\ndepends_on: [490]\n---\n", ) .unwrap(); let archived_deps = check_archived_dependencies(tmp.path(), "1_backlog", "503_story_waiting"); assert!(archived_deps.is_empty()); } }