//! Dependency resolution: check whether story dependencies are satisfied. use std::fs; use std::path::Path; use super::parser::parse_front_matter; /// 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 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 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() } #[cfg(test)] mod tests { use super::*; #[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)); } }