//! Story metadata — parses and modifies YAML front matter in story markdown files. use serde::Deserialize; use std::fs; use std::path::Path; /// QA mode for a story: determines how the pipeline handles post-coder review. /// /// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests). /// If gates pass, advance straight to merge. /// - `Agent` — spin up a QA agent (Claude session) to review code and run gates. /// - `Human` — hold in QA for human approval after server gates pass. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QaMode { Server, Agent, Human, } impl QaMode { /// Parse a string into a `QaMode`. Returns `None` for unrecognised values. pub fn from_str(s: &str) -> Option { match s.trim().to_lowercase().as_str() { "server" => Some(Self::Server), "agent" => Some(Self::Agent), "human" => Some(Self::Human), _ => None, } } pub fn as_str(&self) -> &'static str { match self { Self::Server => "server", Self::Agent => "agent", Self::Human => "human", } } } impl std::fmt::Display for QaMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct StoryMetadata { pub name: Option, pub coverage_baseline: Option, pub merge_failure: Option, pub agent: Option, pub review_hold: Option, pub qa: Option, /// Number of times this story has been retried at its current pipeline stage. pub retry_count: Option, /// When `true`, auto-assign will skip this story (retry limit exceeded). pub blocked: Option, /// Story numbers this story depends on. Auto-assign will skip this story /// until all dependencies have reached `5_done` or `6_archived`. pub depends_on: Option>, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum StoryMetaError { MissingFrontMatter, InvalidFrontMatter(String), } 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}"), } } } #[derive(Debug, Deserialize)] struct FrontMatter { name: Option, coverage_baseline: Option, merge_failure: Option, agent: Option, review_hold: Option, /// Configurable QA mode field: "human", "server", or "agent". qa: Option, /// Number of times this story has been retried at its current pipeline stage. retry_count: Option, /// When `true`, auto-assign will skip this story (retry limit exceeded). blocked: Option, /// Story numbers this story depends on. depends_on: Option>, } pub fn parse_front_matter(contents: &str) -> Result { 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 { let qa = front.qa.as_deref().and_then(QaMode::from_str); StoryMetadata { name: front.name, coverage_baseline: front.coverage_baseline, merge_failure: front.merge_failure, agent: front.agent, review_hold: front.review_hold, qa, retry_count: front.retry_count, blocked: front.blocked, depends_on: front.depends_on, } } /// 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(()) } /// 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 = 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 } /// 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 = 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 } /// 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 a `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 = 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(()) } /// Return the list of dependency story numbers from `story_id`'s front matter /// that have **not** yet reached `5_done` or `6_archived`. /// /// Returns an empty `Vec` when there are no unmet dependencies (including when /// the story has no `depends_on` field at all). pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { let path = project_root .join(".huskies") .join("work") .join(stage_dir) .join(format!("{story_id}.md")); let contents = match fs::read_to_string(&path) { Ok(c) => c, Err(_) => return Vec::new(), }; let deps = match parse_front_matter(&contents) .ok() .and_then(|m| m.depends_on) { Some(d) => d, None => return Vec::new(), }; deps.into_iter() .filter(|&dep| !dep_is_done(project_root, dep)) .collect() } /// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`. /// /// **Dependency semantics:** Both `5_done` and `6_archived` satisfy a `depends_on` entry. /// Stories auto-sweep from `5_done` to `6_archived` after 4 hours, so by the time a dep /// reaches `6_archived`, the dependent story has already been promoted. When a dep is /// already in `6_archived` at the moment of promotion (e.g., it was manually archived or /// abandoned before the dependent story was created), the dependency is still considered /// satisfied — but a warning is logged so the user can see that the dep was archived, not /// cleanly completed. Use `check_archived_deps` to detect this case. fn dep_is_done(project_root: &Path, dep_number: u32) -> bool { let prefix = format!("{dep_number}_"); let exact = dep_number.to_string(); for stage in &["5_done", "6_archived"] { let dir = project_root.join(".huskies").join("work").join(stage); if let Ok(entries) = fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) && (stem == exact || stem.starts_with(&prefix)) { return true; } } } } false } /// Return `true` if a story with the given numeric ID exists specifically in `6_archived` /// (i.e., it satisfies a `depends_on` but via the archive rather than via a clean done). fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool { let prefix = format!("{dep_number}_"); let exact = dep_number.to_string(); let dir = project_root .join(".huskies") .join("work") .join("6_archived"); if let Ok(entries) = fs::read_dir(&dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) && (stem == exact || stem.starts_with(&prefix)) { return true; } } } false } /// Return the list of dependency story numbers from `story_id`'s front matter /// that are in `6_archived` (satisfied via archive rather than via normal done). /// /// Used to emit a warning when backlog promotion fires because a dep was archived /// rather than cleanly completed. Returns an empty `Vec` when no deps are archived. pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { let path = project_root .join(".huskies") .join("work") .join(stage_dir) .join(format!("{story_id}.md")); let contents = match fs::read_to_string(&path) { Ok(c) => c, Err(_) => return Vec::new(), }; let deps = match parse_front_matter(&contents) .ok() .and_then(|m| m.depends_on) { Some(d) => d, None => return Vec::new(), }; deps.into_iter() .filter(|&dep| dep_is_archived(project_root, dep)) .collect() } /// Given an explicit list of dep numbers, return those already in `6_archived`. /// /// Used at story-creation time when the dep list is known in memory (before the /// story file has been written), so the caller does not need to parse the story. pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec { deps.iter() .copied() .filter(|&dep| dep_is_archived(project_root, dep)) .collect() } // ── In-memory content variants (no filesystem access) ─────────────── /// 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}") } /// Resolve the effective QA mode from story content (no filesystem access). /// /// Parses front matter from `contents` and returns the `qa` field if present, /// otherwise returns `default`. pub fn resolve_qa_mode_from_content(contents: &str, default: QaMode) -> QaMode { match parse_front_matter(contents) { Ok(meta) => meta.qa.unwrap_or(default), Err(_) => default, } } /// Increment the `retry_count` field in story content (pure function). /// /// Returns `(updated_content, new_count)`. pub fn increment_retry_count_in_content(contents: &str) -> (String, u32) { let current = parse_front_matter(contents) .ok() .and_then(|m| m.retry_count) .unwrap_or(0); let new_count = current + 1; let updated = set_front_matter_field(contents, "retry_count", &new_count.to_string()); (updated, new_count) } /// 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 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 = deps.iter().map(|n| n.to_string()).collect(); let yaml_value = format!("[{}]", nums.join(", ")); set_front_matter_field(contents, "depends_on", &yaml_value) } } /// Resolve the effective QA mode for a story file. /// /// Reads the `qa` front matter field. If absent, falls back to `default`. /// Spikes are **not** handled here — the caller is responsible for overriding /// to `Human` for spikes. pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode { let contents = match fs::read_to_string(path) { Ok(c) => c, Err(_) => return default, }; match parse_front_matter(&contents) { Ok(meta) => meta.qa.unwrap_or(default), Err(_) => default, } } pub fn parse_unchecked_todos(contents: &str) -> Vec { contents .lines() .filter_map(|line| { let trimmed = line.trim(); trimmed.strip_prefix("- [ ] ").map(|text| text.to_string()) }) .collect() } #[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"); 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 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(_)) )); } #[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 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"]); } #[test] fn parses_review_hold_from_front_matter() { let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.review_hold, Some(true)); } #[test] fn review_hold_defaults_to_none() { let input = "---\nname: Story\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.review_hold, None); } #[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 parses_qa_mode_from_front_matter() { let input = "---\nname: Story\nqa: server\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Server)); let input = "---\nname: Story\nqa: agent\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Agent)); let input = "---\nname: Story\nqa: human\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, Some(QaMode::Human)); } #[test] fn qa_mode_defaults_to_none() { let input = "---\nname: Story\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.qa, None); } #[test] fn resolve_qa_mode_uses_file_value() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap(); assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human); } #[test] fn resolve_qa_mode_falls_back_to_default() { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("story.md"); std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server); assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent); } #[test] fn resolve_qa_mode_missing_file_uses_default() { let path = std::path::Path::new("/nonexistent/story.md"); assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server); } #[test] fn parses_depends_on_from_front_matter() { let input = "---\nname: Story\ndepends_on: [477, 478]\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.depends_on, Some(vec![477, 478])); } #[test] fn depends_on_defaults_to_none() { let input = "---\nname: Story\n---\n# Story\n"; let meta = parse_front_matter(input).expect("front matter"); assert_eq!(meta.depends_on, None); } #[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}"); } #[test] fn check_unmet_deps_returns_empty_when_no_deps() { let tmp = tempfile::tempdir().unwrap(); let stage = tmp.path().join(".huskies/work/2_current"); std::fs::create_dir_all(&stage).unwrap(); std::fs::write(stage.join("10_story_foo.md"), "---\nname: Foo\n---\n").unwrap(); let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); assert!(unmet.is_empty()); } #[test] fn check_unmet_deps_returns_unmet_numbers() { 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(); // Dep 477 is done, dep 478 is not. std::fs::write(done.join("477_story_dep.md"), "---\nname: Dep\n---\n").unwrap(); std::fs::write( current.join("10_story_foo.md"), "---\nname: Foo\ndepends_on: [477, 478]\n---\n", ) .unwrap(); let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); assert_eq!(unmet, vec![478]); } #[test] fn check_unmet_deps_returns_empty_when_all_deps_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("477_story_a.md"), "---\nname: A\n---\n").unwrap(); std::fs::write(done.join("478_story_b.md"), "---\nname: B\n---\n").unwrap(); std::fs::write( current.join("10_story_foo.md"), "---\nname: Foo\ndepends_on: [477, 478]\n---\n", ) .unwrap(); let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); assert!(unmet.is_empty()); } #[test] fn dep_is_done_finds_story_in_archived() { let tmp = tempfile::tempdir().unwrap(); let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(&archived).unwrap(); std::fs::write(archived.join("100_story_old.md"), "---\nname: Old\n---\n").unwrap(); assert!(dep_is_done(tmp.path(), 100)); assert!(!dep_is_done(tmp.path(), 101)); } // ── Bug 503: archived-dep visibility ───────────────────────────────────── /// check_archived_deps returns the dep IDs that are in 6_archived. #[test] fn check_archived_deps_returns_archived_dep_numbers() { let tmp = tempfile::tempdir().unwrap(); let current = tmp.path().join(".huskies/work/2_current"); let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(¤t).unwrap(); std::fs::create_dir_all(&archived).unwrap(); // Dep 100 is in 6_archived; dep 101 is not anywhere. std::fs::write(archived.join("100_spike_old.md"), "---\nname: Old\n---\n").unwrap(); std::fs::write( current.join("5_story_dependent.md"), "---\nname: Dep\ndepends_on: [100, 101]\n---\n", ) .unwrap(); let archived_deps = check_archived_deps(tmp.path(), "2_current", "5_story_dependent"); assert_eq!(archived_deps, vec![100]); } /// check_archived_deps returns empty when no deps are in 6_archived. #[test] fn check_archived_deps_returns_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(); // Dep 200 is in 5_done (not archived). std::fs::write(done.join("200_story_done.md"), "---\nname: Done\n---\n").unwrap(); std::fs::write( backlog.join("5_story_waiting.md"), "---\nname: Waiting\ndepends_on: [200]\n---\n", ) .unwrap(); let archived_deps = check_archived_deps(tmp.path(), "1_backlog", "5_story_waiting"); assert!(archived_deps.is_empty()); } /// check_archived_deps returns empty when story has no depends_on. #[test] fn check_archived_deps_returns_empty_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("3_story_free.md"), "---\nname: Free\n---\n").unwrap(); let archived_deps = check_archived_deps(tmp.path(), "2_current", "3_story_free"); assert!(archived_deps.is_empty()); } /// check_archived_deps_from_list returns archived dep IDs from an in-memory list. #[test] fn check_archived_deps_from_list_returns_archived_ids() { let tmp = tempfile::tempdir().unwrap(); let done = tmp.path().join(".huskies/work/5_done"); let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(&done).unwrap(); std::fs::create_dir_all(&archived).unwrap(); std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); // Only 20 is archived; 10 is in done, 30 is nowhere. let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]); assert_eq!(result, vec![20]); } /// check_archived_deps_from_list returns empty when no deps are archived. #[test] fn check_archived_deps_from_list_empty_when_no_archived_deps() { let tmp = tempfile::tempdir().unwrap(); let done = tmp.path().join(".huskies/work/5_done"); std::fs::create_dir_all(&done).unwrap(); std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); let result = check_archived_deps_from_list(tmp.path(), &[10]); assert!(result.is_empty()); } /// dep_is_archived returns true only for stories in 6_archived, not 5_done. #[test] fn dep_is_archived_distinguishes_done_from_archived() { let tmp = tempfile::tempdir().unwrap(); let done = tmp.path().join(".huskies/work/5_done"); let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(&done).unwrap(); std::fs::create_dir_all(&archived).unwrap(); std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); // 10 is in 5_done only — not archived. assert!(!dep_is_archived(tmp.path(), 10)); // 20 is in 6_archived — archived. assert!(dep_is_archived(tmp.path(), 20)); // 99 doesn't exist anywhere. assert!(!dep_is_archived(tmp.path(), 99)); } }