283 lines
12 KiB
Rust
283 lines
12 KiB
Rust
//! 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<u32> {
|
|
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<u32> {
|
|
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<u32> {
|
|
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));
|
|
}
|
|
}
|