use std::collections::HashSet;
use crate::model::Project;
use crate::model::task::{Metadata, Task, TaskState};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DepStatus {
Resolved,
Cycle,
Repeat,
Missing,
}
#[derive(Debug, Clone)]
pub struct DepNode {
pub id: String,
pub status: DepStatus,
pub track_id: Option<String>,
pub title: Option<String>,
pub state: Option<TaskState>,
pub tags: Vec<String>,
pub deps: Vec<DepNode>,
}
impl DepNode {
fn terminal(id: &str, status: DepStatus) -> Self {
DepNode {
id: id.to_string(),
status,
track_id: None,
title: None,
state: None,
tags: Vec::new(),
deps: Vec::new(),
}
}
}
pub fn task_deps(task: &Task) -> Vec<String> {
let mut deps = Vec::new();
for m in &task.metadata {
if let Metadata::Dep(d) = m {
deps.extend(d.iter().cloned());
}
}
deps
}
fn find_task<'a>(project: &'a Project, id: &str) -> Option<(&'a str, &'a Task)> {
project.tracks.iter().find_map(|(track_id, track)| {
crate::ops::task_ops::find_task_in_track(track, id).map(|task| (track_id.as_str(), task))
})
}
pub fn dep_tree(project: &Project, root_id: &str) -> DepNode {
let mut path = Vec::new();
let mut expanded = HashSet::new();
build(project, root_id, &mut path, &mut expanded)
}
fn build(
project: &Project,
id: &str,
path: &mut Vec<String>,
expanded: &mut HashSet<String>,
) -> DepNode {
if path.iter().any(|p| p == id) {
return DepNode::terminal(id, DepStatus::Cycle);
}
let Some((track_id, task)) = find_task(project, id) else {
return DepNode::terminal(id, DepStatus::Missing);
};
if !expanded.insert(id.to_string()) {
return DepNode::terminal(id, DepStatus::Repeat);
}
path.push(id.to_string());
let deps = task_deps(task)
.iter()
.map(|dep_id| build(project, dep_id, path, expanded))
.collect();
path.pop();
DepNode {
id: id.to_string(),
status: DepStatus::Resolved,
track_id: Some(track_id.to_string()),
title: Some(task.title.clone()),
state: Some(task.state),
tags: task.tags.clone(),
deps,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ProjectConfig, ProjectInfo};
use crate::parse::parse_track;
use std::path::PathBuf;
fn project(track_md: &str) -> Project {
Project {
root: PathBuf::from("/tmp/deps-test"),
frame_dir: PathBuf::from("/tmp/deps-test/frame"),
config: ProjectConfig {
project: ProjectInfo {
name: "Test".into(),
},
agent: Default::default(),
tracks: vec![],
clean: Default::default(),
ids: Default::default(),
ui: Default::default(),
},
tracks: vec![("main".to_string(), parse_track(track_md))],
inbox: None,
}
}
fn fixture() -> Project {
project(
"# Main\n\n## Backlog\n\n\
- [ ] `M-001` Root\n - dep: M-002, M-003\n\
- [ ] `M-002` Left\n - dep: M-004\n\
- [ ] `M-003` Right\n - dep: M-004\n\
- [ ] `M-004` Shared leaf\n\
- [ ] `M-005` Cycle a\n - dep: M-006\n\
- [ ] `M-006` Cycle b\n - dep: M-005\n\
- [ ] `M-007` Dangling\n - dep: M-999\n\n## Done\n",
)
}
fn kid(node: &DepNode, index: usize) -> &DepNode {
&node.deps[index]
}
#[test]
fn a_shared_dependency_is_a_repeat_not_a_cycle() {
let tree = dep_tree(&fixture(), "M-001");
assert_eq!(tree.status, DepStatus::Resolved);
let left = kid(&tree, 0);
assert_eq!(left.id, "M-002");
assert_eq!(kid(left, 0).id, "M-004");
assert_eq!(kid(left, 0).status, DepStatus::Resolved);
let right = kid(&tree, 1);
assert_eq!(right.id, "M-003");
assert_eq!(kid(right, 0).id, "M-004");
assert_eq!(
kid(right, 0).status,
DepStatus::Repeat,
"a diamond is not a cycle"
);
}
#[test]
fn a_real_cycle_stops_at_the_root() {
let tree = dep_tree(&fixture(), "M-005");
let b = kid(&tree, 0);
assert_eq!(b.id, "M-006");
assert_eq!(b.status, DepStatus::Resolved);
let back = kid(b, 0);
assert_eq!(back.id, "M-005");
assert_eq!(back.status, DepStatus::Cycle);
assert!(back.deps.is_empty(), "a cycle node expands nothing");
}
#[test]
fn a_dangling_dep_is_missing() {
let tree = dep_tree(&fixture(), "M-007");
let dangling = kid(&tree, 0);
assert_eq!(dangling.id, "M-999");
assert_eq!(dangling.status, DepStatus::Missing);
assert_eq!(dangling.title, None);
}
#[test]
fn a_root_with_no_deps_has_no_children() {
let tree = dep_tree(&fixture(), "M-004");
assert_eq!(tree.status, DepStatus::Resolved);
assert!(tree.deps.is_empty());
}
#[test]
fn an_unknown_root_is_missing() {
assert_eq!(dep_tree(&fixture(), "M-999").status, DepStatus::Missing);
}
#[test]
fn a_self_dependency_is_a_cycle() {
let p = project("# Main\n\n## Backlog\n\n- [ ] `M-001` Self\n - dep: M-001\n\n## Done\n");
let tree = dep_tree(&p, "M-001");
assert_eq!(tree.status, DepStatus::Resolved);
assert_eq!(kid(&tree, 0).status, DepStatus::Cycle);
}
}