1use crate::task::{self, Priority, Status, Task};
2use serde::Serialize;
3use std::collections::{HashMap, HashSet, VecDeque};
4
5pub fn is_task_ready(tasks: &HashMap<String, Task>, task: &Task) -> bool {
17 task.status == Status::Open
18 && task.task_type.is_task()
19 && task
20 .depends_on
21 .iter()
22 .all(|dep_id| match tasks.get(dep_id) {
23 Some(dep) => dep.status == Status::Done,
24 None => false, })
26}
27
28pub struct Graph {
30 pub edges: HashMap<String, HashSet<String>>,
32 pub reverse: HashMap<String, HashSet<String>>,
34}
35
36impl Graph {
37 pub fn build(tasks: &HashMap<String, Task>) -> Self {
39 let mut edges: HashMap<String, HashSet<String>> = HashMap::new();
40 let mut reverse: HashMap<String, HashSet<String>> = HashMap::new();
41
42 for task in tasks.values() {
43 edges.entry(task.id.clone()).or_default();
44 reverse.entry(task.id.clone()).or_default();
45
46 for dep in &task.depends_on {
47 edges
48 .entry(task.id.clone())
49 .or_default()
50 .insert(dep.clone());
51 reverse
52 .entry(dep.clone())
53 .or_default()
54 .insert(task.id.clone());
55 }
56 }
57
58 Graph { edges, reverse }
59 }
60
61 pub fn ready<'a>(
66 &self,
67 tasks: &'a HashMap<String, Task>,
68 tag: Option<&str>,
69 limit: Option<usize>,
70 epic: Option<&str>,
71 ) -> Vec<&'a Task> {
72 let eff = self.effective_priorities_all(tasks);
73
74 let mut result: Vec<&Task> = tasks
75 .values()
76 .filter(|t| is_task_ready(tasks, t))
77 .filter(|t| task::matches_tag(t, tag))
78 .filter(|t| epic.is_none_or(|e| t.parent.as_deref() == Some(e)))
79 .collect();
80
81 result.sort_by(|a, b| {
84 eff.get(&a.id)
85 .copied()
86 .unwrap_or(a.priority)
87 .cmp(&eff.get(&b.id).copied().unwrap_or(b.priority))
88 .then(a.created.cmp(&b.created))
89 });
90
91 if let Some(limit) = limit {
92 result.truncate(limit);
93 }
94
95 result
96 }
97
98 #[cfg_attr(not(test), allow(dead_code))]
105 pub fn effective_priority(&self, id: &str, tasks: &HashMap<String, Task>) -> Priority {
106 self.effective_priorities_all(tasks)
107 .remove(id)
108 .unwrap_or_else(|| tasks.get(id).map(|t| t.priority).unwrap_or(Priority::P3))
109 }
110
111 pub fn effective_priorities_all(
120 &self,
121 tasks: &HashMap<String, Task>,
122 ) -> HashMap<String, Priority> {
123 let all_ids: HashSet<&str> = self
127 .edges
128 .keys()
129 .chain(self.reverse.keys())
130 .map(String::as_str)
131 .collect();
132
133 let mut in_degree: HashMap<&str, usize> = all_ids.iter().map(|&id| (id, 0)).collect();
135 for id in &all_ids {
136 if let Some(deps) = self.edges.get(*id) {
137 for dep in deps {
138 if all_ids.contains(dep.as_str()) {
139 *in_degree.entry(id).or_default() += 1;
140 }
141 }
142 }
143 }
144
145 let mut queue: VecDeque<&str> = in_degree
146 .iter()
147 .filter(|(_, d)| **d == 0)
148 .map(|(&id, _)| id)
149 .collect();
150 let mut topo_order: Vec<&str> = Vec::with_capacity(all_ids.len());
151 while let Some(current) = queue.pop_front() {
152 topo_order.push(current);
153 if let Some(dependents) = self.reverse.get(current) {
154 for dep in dependents {
155 if let Some(d) = in_degree.get_mut(dep.as_str()) {
156 *d -= 1;
157 if *d == 0 {
158 queue.push_back(dep.as_str());
159 }
160 }
161 }
162 }
163 }
164 let mut eff: HashMap<String, Priority> = HashMap::with_capacity(all_ids.len());
169
170 for &id in &all_ids {
172 let own = tasks.get(id).map(|t| t.priority).unwrap_or(Priority::P3);
173 eff.insert(id.to_string(), own);
174 }
175
176 for &id in topo_order.iter().rev() {
178 let best_dependent: Option<Priority> = self
180 .reverse
181 .get(id)
182 .into_iter()
183 .flat_map(|s| s.iter())
184 .filter_map(|dep_id| eff.get(dep_id.as_str()).copied())
185 .min();
186
187 if let Some(best) = best_dependent {
188 let entry = eff.entry(id.to_string()).or_insert(Priority::P3);
189 *entry = (*entry).min(best);
190 }
191 }
192
193 eff
194 }
195
196 pub fn would_cycle(&self, from: &str, to: &str) -> bool {
199 if from == to {
200 return true;
201 }
202
203 let mut visited = HashSet::new();
206 let mut queue = VecDeque::new();
207 queue.push_back(to.to_string());
208
209 while let Some(current) = queue.pop_front() {
210 if current == from {
211 return true;
212 }
213 if !visited.insert(current.clone()) {
214 continue;
215 }
216 if let Some(deps) = self.edges.get(¤t) {
217 for dep in deps {
218 queue.push_back(dep.clone());
219 }
220 }
221 }
222
223 false
224 }
225
226 pub fn dep_tree<'a>(&self, tasks: &'a HashMap<String, Task>, id: &str) -> Option<DepNode<'a>> {
235 let mut visiting = HashSet::new();
236 let mut seen = HashSet::new();
237 self.dep_tree_inner(tasks, id, &mut visiting, &mut seen)
238 }
239
240 fn dep_tree_inner<'a>(
241 &self,
242 tasks: &'a HashMap<String, Task>,
243 id: &str,
244 visiting: &mut HashSet<String>,
245 seen: &mut HashSet<String>,
246 ) -> Option<DepNode<'a>> {
247 let task = tasks.get(id)?;
248
249 if !visiting.insert(id.to_string()) {
250 return Some(DepNode {
252 task,
253 children: Vec::new(),
254 cycle: true,
255 seen: false,
256 });
257 }
258
259 if !seen.insert(id.to_string()) {
260 visiting.remove(id);
263 return Some(DepNode {
264 task,
265 children: Vec::new(),
266 cycle: false,
267 seen: true,
268 });
269 }
270
271 let children = task
272 .depends_on
273 .iter()
274 .filter_map(|dep_id| self.dep_tree_inner(tasks, dep_id, visiting, seen))
275 .collect();
276
277 visiting.remove(id);
278
279 Some(DepNode {
280 task,
281 children,
282 cycle: false,
283 seen: false,
284 })
285 }
286
287 pub fn topo_sort_subset<'a>(
293 &self,
294 subset: &HashSet<String>,
295 tasks: &'a HashMap<String, Task>,
296 ) -> SubsetTopo<'a> {
297 if subset.is_empty() {
298 return SubsetTopo::default();
299 }
300
301 let mut in_degree: HashMap<&str, usize> = HashMap::new();
303 for id in subset {
304 in_degree.insert(id.as_str(), 0);
305 }
306 for id in subset {
307 if let Some(deps) = self.edges.get(id) {
308 for dep in deps {
309 if subset.contains(dep) {
310 *in_degree.entry(id.as_str()).or_default() += 1;
311 }
312 }
313 }
314 }
315
316 let mut seed: Vec<&str> = in_degree
325 .iter()
326 .filter(|&(_, deg)| *deg == 0)
327 .map(|(&id, _)| id)
328 .collect();
329 seed.sort_by(|a, b| {
330 let ta = tasks.get(*a);
331 let tb = tasks.get(*b);
332 match (ta, tb) {
333 (Some(ta), Some(tb)) => ta
334 .priority
335 .cmp(&tb.priority)
336 .then(ta.created.cmp(&tb.created)),
337 _ => std::cmp::Ordering::Equal,
338 }
339 });
340 let mut queue: VecDeque<&str> = seed.into_iter().collect();
341 let mut result: Vec<&'a Task> = Vec::new();
342 while let Some(current) = queue.pop_front() {
343 if let Some(task) = tasks.get(current) {
344 result.push(task);
345 }
346
347 if let Some(dependents) = self.reverse.get(current) {
349 let mut newly_ready: Vec<&str> = Vec::new();
350 for dep in dependents {
351 if subset.contains(dep)
352 && let Some(deg) = in_degree.get_mut(dep.as_str())
353 {
354 *deg -= 1;
355 if *deg == 0 {
356 newly_ready.push(dep.as_str());
357 }
358 }
359 }
360 newly_ready.sort_by(|a, b| {
362 let ta = tasks.get(*a);
363 let tb = tasks.get(*b);
364 match (ta, tb) {
365 (Some(ta), Some(tb)) => ta
366 .priority
367 .cmp(&tb.priority)
368 .then(ta.created.cmp(&tb.created)),
369 _ => std::cmp::Ordering::Equal,
370 }
371 });
372 queue.extend(newly_ready);
373 }
374 }
375
376 let sorted_ids: HashSet<&str> = result.iter().map(|t| t.id.as_str()).collect();
378 let mut cyclic: Vec<&'a Task> = subset
379 .iter()
380 .filter(|id| !sorted_ids.contains(id.as_str()))
381 .filter_map(|id| tasks.get(id))
382 .collect();
383 cyclic.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.created.cmp(&b.created)));
384
385 SubsetTopo {
386 sorted: result,
387 cyclic,
388 }
389 }
390
391 #[cfg_attr(not(test), allow(dead_code))]
393 pub fn adjacency_list(&self) -> HashMap<&str, Vec<&str>> {
394 self.edges
395 .iter()
396 .map(|(k, v)| {
397 let deps: Vec<&str> = v.iter().map(|s| s.as_str()).collect();
398 (k.as_str(), deps)
399 })
400 .collect()
401 }
402
403 pub fn bounded_adjacency_list<'a>(
414 &'a self,
415 tasks: &'a HashMap<String, Task>,
416 include_done: bool,
417 epic: Option<&str>,
418 limit: Option<usize>,
419 ) -> HashMap<&'a str, Vec<&'a str>> {
420 use crate::task::Status;
421
422 let eligible: HashSet<&str> = tasks
424 .values()
425 .filter(|t| {
426 if !include_done && (t.status == Status::Done || t.status == Status::Cancelled) {
428 return false;
429 }
430 if let Some(e) = epic {
432 return t.id == e || t.parent.as_deref() == Some(e);
433 }
434 true
435 })
436 .map(|t| t.id.as_str())
437 .collect();
438
439 let mut connected: HashSet<&str> = HashSet::new();
442 for &id in &eligible {
443 if let Some(deps) = self.edges.get(id) {
444 for dep in deps {
445 if eligible.contains(dep.as_str()) {
446 connected.insert(id);
447 connected.insert(dep.as_str());
448 }
449 }
450 }
451 }
452
453 let mut result: Vec<(&str, Vec<&str>)> = connected
455 .iter()
456 .map(|&id| {
457 let deps: Vec<&str> = self
458 .edges
459 .get(id)
460 .into_iter()
461 .flat_map(|s| s.iter())
462 .filter(|dep| eligible.contains(dep.as_str()))
463 .map(|s| s.as_str())
464 .collect();
465 (id, deps)
466 })
467 .collect();
468
469 result.sort_by_key(|(id, _)| *id);
471
472 if let Some(limit) = limit {
474 result.truncate(limit);
475 }
476
477 result.into_iter().collect()
478 }
479}
480
481#[derive(Default)]
483pub struct SubsetTopo<'a> {
484 pub sorted: Vec<&'a Task>,
486 pub cyclic: Vec<&'a Task>,
488}
489
490pub struct DepNode<'a> {
491 pub task: &'a Task,
492 pub children: Vec<DepNode<'a>>,
493 pub cycle: bool,
495 pub seen: bool,
498}
499
500#[derive(Serialize)]
501pub struct DepNodeJson {
502 pub id: String,
503 pub title: String,
504 pub status: String,
505 pub priority: Priority,
506 pub children: Vec<DepNodeJson>,
507 #[serde(skip_serializing_if = "std::ops::Not::not")]
508 pub cycle: bool,
509 #[serde(skip_serializing_if = "std::ops::Not::not")]
510 pub seen: bool,
511}
512
513impl DepNodeJson {
514 pub fn from_dep_node(node: &DepNode<'_>) -> Self {
515 DepNodeJson {
516 id: node.task.id.clone(),
517 title: node.task.title.clone(),
518 status: node.task.status.to_string(),
519 priority: node.task.priority,
520 children: node
521 .children
522 .iter()
523 .map(DepNodeJson::from_dep_node)
524 .collect(),
525 cycle: node.cycle,
526 seen: node.seen,
527 }
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::task::TaskType;
535 use chrono::Utc;
536
537 fn make_task(id: &str, status: Status, priority: Priority, deps: Vec<&str>) -> Task {
538 Task {
539 id: id.into(),
540 title: format!("Task {id}"),
541 task_type: TaskType::default(),
542 status,
543 priority,
544 created: Utc::now(),
545 updated: Utc::now(),
546 tags: Vec::new(),
547 depends_on: deps.into_iter().map(String::from).collect(),
548 parent: None,
549 assignee: String::new(),
550 attempts: None,
551 body: String::new(),
552 }
553 }
554
555 fn make_tasks(tasks: Vec<Task>) -> HashMap<String, Task> {
556 tasks.into_iter().map(|t| (t.id.clone(), t)).collect()
557 }
558
559 #[test]
560 fn test_ready_no_deps() {
561 let tasks = make_tasks(vec![
562 make_task("a", Status::Open, Priority::P1, vec![]),
563 make_task("b", Status::Open, Priority::P0, vec![]),
564 ]);
565 let graph = Graph::build(&tasks);
566 let ready = graph.ready(&tasks, None, None, None);
567 assert_eq!(ready.len(), 2);
569 assert_eq!(ready[0].id, "b");
570 assert_eq!(ready[1].id, "a");
571 }
572
573 #[test]
574 fn test_ready_with_deps() {
575 let tasks = make_tasks(vec![
576 make_task("a", Status::Done, Priority::P1, vec![]),
577 make_task("b", Status::Open, Priority::P1, vec!["a"]),
578 make_task("c", Status::Open, Priority::P1, vec!["b"]),
579 ]);
580 let graph = Graph::build(&tasks);
581 let ready = graph.ready(&tasks, None, None, None);
582 assert_eq!(ready.len(), 1);
584 assert_eq!(ready[0].id, "b");
585 }
586
587 #[test]
588 fn test_ready_blocked_by_undone_dep() {
589 let tasks = make_tasks(vec![
590 make_task("a", Status::InProgress, Priority::P1, vec![]),
591 make_task("b", Status::Open, Priority::P1, vec!["a"]),
592 ]);
593 let graph = Graph::build(&tasks);
594 let ready = graph.ready(&tasks, None, None, None);
595 assert!(ready.is_empty());
596 }
597
598 #[test]
599 fn test_ready_blocked_by_missing_dep() {
600 let tasks = make_tasks(vec![make_task(
602 "b",
603 Status::Open,
604 Priority::P1,
605 vec!["nonexistent"],
606 )]);
607 let graph = Graph::build(&tasks);
608 let ready = graph.ready(&tasks, None, None, None);
609 assert!(ready.is_empty());
610 }
611
612 #[test]
613 fn test_ready_with_tag_filter() {
614 let mut t = make_task("a", Status::Open, Priority::P1, vec![]);
615 t.tags = vec!["backend".into()];
616 let tasks = make_tasks(vec![t, make_task("b", Status::Open, Priority::P1, vec![])]);
617 let graph = Graph::build(&tasks);
618
619 let ready = graph.ready(&tasks, Some("backend"), None, None);
620 assert_eq!(ready.len(), 1);
621 assert_eq!(ready[0].id, "a");
622 }
623
624 #[test]
625 fn test_ready_with_limit() {
626 let tasks = make_tasks(vec![
627 make_task("a", Status::Open, Priority::P1, vec![]),
628 make_task("b", Status::Open, Priority::P1, vec![]),
629 make_task("c", Status::Open, Priority::P1, vec![]),
630 ]);
631 let graph = Graph::build(&tasks);
632 let ready = graph.ready(&tasks, None, Some(2), None);
633 assert_eq!(ready.len(), 2);
634 }
635
636 #[test]
637 fn test_ready_excludes_epics() {
638 let mut epic = make_task("e", Status::Open, Priority::P0, vec![]);
639 epic.task_type = TaskType::Epic;
640 let tasks = make_tasks(vec![
641 epic,
642 make_task("a", Status::Open, Priority::P1, vec![]),
643 ]);
644 let graph = Graph::build(&tasks);
645 let ready = graph.ready(&tasks, None, None, None);
646 assert_eq!(ready.len(), 1);
648 assert_eq!(ready[0].id, "a");
649 }
650
651 #[test]
652 fn test_ready_with_epic_filter() {
653 let mut t1 = make_task("a", Status::Open, Priority::P1, vec![]);
654 t1.parent = Some("epic1".into());
655 let mut t2 = make_task("b", Status::Open, Priority::P1, vec![]);
656 t2.parent = Some("epic2".into());
657 let t3 = make_task("c", Status::Open, Priority::P1, vec![]);
658 let tasks = make_tasks(vec![t1, t2, t3]);
659 let graph = Graph::build(&tasks);
660
661 let ready = graph.ready(&tasks, None, None, Some("epic1"));
663 assert_eq!(ready.len(), 1);
664 assert_eq!(ready[0].id, "a");
665
666 let ready = graph.ready(&tasks, None, None, Some("epic2"));
668 assert_eq!(ready.len(), 1);
669 assert_eq!(ready[0].id, "b");
670
671 let ready = graph.ready(&tasks, None, None, None);
673 assert_eq!(ready.len(), 3);
674 }
675
676 #[test]
679 fn test_is_task_ready_no_deps() {
680 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
681 assert!(is_task_ready(&tasks, tasks.get("a").unwrap()));
682 }
683
684 #[test]
685 fn test_is_task_ready_dep_done() {
686 let tasks = make_tasks(vec![
687 make_task("dep", Status::Done, Priority::P1, vec![]),
688 make_task("a", Status::Open, Priority::P1, vec!["dep"]),
689 ]);
690 assert!(is_task_ready(&tasks, tasks.get("a").unwrap()));
691 }
692
693 #[test]
694 fn test_is_task_ready_dep_not_done() {
695 let tasks = make_tasks(vec![
696 make_task("dep", Status::Open, Priority::P1, vec![]),
697 make_task("a", Status::Open, Priority::P1, vec!["dep"]),
698 ]);
699 assert!(!is_task_ready(&tasks, tasks.get("a").unwrap()));
700 }
701
702 #[test]
703 fn test_is_task_ready_missing_dep_blocks() {
704 let tasks = make_tasks(vec![make_task(
706 "a",
707 Status::Open,
708 Priority::P1,
709 vec!["nonexistent"],
710 )]);
711 assert!(!is_task_ready(&tasks, tasks.get("a").unwrap()));
712 }
713
714 #[test]
715 fn test_is_task_ready_excludes_epics() {
716 let mut epic = make_task("e", Status::Open, Priority::P0, vec![]);
717 epic.task_type = TaskType::Epic;
718 let tasks = make_tasks(vec![epic]);
719 assert!(!is_task_ready(&tasks, tasks.get("e").unwrap()));
720 }
721
722 #[test]
723 fn test_is_task_ready_excludes_non_open() {
724 let tasks = make_tasks(vec![make_task(
725 "a",
726 Status::InProgress,
727 Priority::P1,
728 vec![],
729 )]);
730 assert!(!is_task_ready(&tasks, tasks.get("a").unwrap()));
731 }
732
733 #[test]
736 fn test_ready_and_is_task_ready_agree() {
737 let tasks = make_tasks(vec![
738 make_task("t1", Status::Open, Priority::P1, vec![]),
740 make_task("t2", Status::Open, Priority::P1, vec!["t1"]),
742 make_task("t3", Status::Open, Priority::P1, vec!["t4"]),
744 make_task("t4", Status::Done, Priority::P1, vec![]),
746 make_task("t5", Status::Open, Priority::P1, vec!["ghost"]),
748 ]);
749 let graph = Graph::build(&tasks);
750 let ready_ids: std::collections::HashSet<&str> = graph
751 .ready(&tasks, None, None, None)
752 .iter()
753 .map(|t| t.id.as_str())
754 .collect();
755
756 for task in tasks.values() {
757 let predicate = is_task_ready(&tasks, task);
758 let in_ready = ready_ids.contains(task.id.as_str());
759 assert_eq!(
760 predicate, in_ready,
761 "is_task_ready and graph.ready disagree on task '{}'",
762 task.id
763 );
764 }
765 }
766
767 #[test]
768 fn test_cycle_self() {
769 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
770 let graph = Graph::build(&tasks);
771 assert!(graph.would_cycle("a", "a"));
772 }
773
774 #[test]
775 fn test_cycle_direct() {
776 let tasks = make_tasks(vec![
778 make_task("a", Status::Open, Priority::P1, vec!["b"]),
779 make_task("b", Status::Open, Priority::P1, vec![]),
780 ]);
781 let graph = Graph::build(&tasks);
782 assert!(graph.would_cycle("b", "a"));
783 }
784
785 #[test]
786 fn test_cycle_transitive() {
787 let tasks = make_tasks(vec![
789 make_task("a", Status::Open, Priority::P1, vec!["b"]),
790 make_task("b", Status::Open, Priority::P1, vec!["c"]),
791 make_task("c", Status::Open, Priority::P1, vec![]),
792 ]);
793 let graph = Graph::build(&tasks);
794 assert!(graph.would_cycle("c", "a"));
795 }
796
797 #[test]
798 fn test_no_cycle() {
799 let tasks = make_tasks(vec![
800 make_task("a", Status::Open, Priority::P1, vec![]),
801 make_task("b", Status::Open, Priority::P1, vec![]),
802 ]);
803 let graph = Graph::build(&tasks);
804 assert!(!graph.would_cycle("a", "b"));
805 }
806
807 #[test]
808 fn test_dep_tree() {
809 let tasks = make_tasks(vec![
810 make_task("a", Status::Open, Priority::P1, vec!["b", "c"]),
811 make_task("b", Status::Open, Priority::P1, vec![]),
812 make_task("c", Status::Open, Priority::P1, vec![]),
813 ]);
814 let graph = Graph::build(&tasks);
815 let tree = graph.dep_tree(&tasks, "a").unwrap();
816 assert_eq!(tree.task.id, "a");
817 assert_eq!(tree.children.len(), 2);
818 }
819
820 #[test]
821 fn test_empty_graph() {
822 let tasks: HashMap<String, Task> = HashMap::new();
823 let graph = Graph::build(&tasks);
824 let ready = graph.ready(&tasks, None, None, None);
825 assert!(ready.is_empty());
826 }
827
828 #[test]
829 fn test_adjacency_list() {
830 let tasks = make_tasks(vec![
831 make_task("a", Status::Open, Priority::P1, vec!["b"]),
832 make_task("b", Status::Open, Priority::P1, vec![]),
833 ]);
834 let graph = Graph::build(&tasks);
835 let adj = graph.adjacency_list();
836 assert_eq!(adj.get("a").unwrap().len(), 1);
837 assert!(adj.get("b").unwrap().is_empty());
838 }
839
840 #[test]
841 fn test_bounded_adjacency_list_excludes_done_and_isolated() {
842 let tasks = make_tasks(vec![
847 make_task("a", Status::Open, Priority::P1, vec!["b"]),
848 make_task("b", Status::Open, Priority::P1, vec![]),
849 make_task("c", Status::Done, Priority::P1, vec![]),
850 make_task("d", Status::Open, Priority::P1, vec![]),
851 ]);
852 let graph = Graph::build(&tasks);
853
854 let adj = graph.bounded_adjacency_list(&tasks, false, None, None);
856 assert!(adj.contains_key("a"), "connected open node a should appear");
857 assert!(adj.contains_key("b"), "connected open node b should appear");
858 assert!(!adj.contains_key("c"), "done node c should be excluded");
859 assert!(
860 !adj.contains_key("d"),
861 "isolated open node d should be excluded"
862 );
863
864 let adj_all = graph.bounded_adjacency_list(&tasks, true, None, None);
866 assert!(adj_all.contains_key("a"));
867 assert!(adj_all.contains_key("b"));
868 assert!(
869 !adj_all.contains_key("c"),
870 "isolated done node c excluded even with include_done=true"
871 );
872 assert!(
873 !adj_all.contains_key("d"),
874 "isolated open node d still excluded with include_done=true"
875 );
876 }
877
878 #[test]
879 fn test_dep_tree_direct_cycle() {
880 let tasks = make_tasks(vec![
882 make_task("a", Status::Open, Priority::P1, vec!["b"]),
883 make_task("b", Status::Open, Priority::P1, vec!["a"]),
884 ]);
885 let graph = Graph::build(&tasks);
886 let tree = graph.dep_tree(&tasks, "a").unwrap();
887 assert!(!tree.cycle);
888 assert_eq!(tree.children.len(), 1);
889 let b_node = &tree.children[0];
891 assert_eq!(b_node.task.id, "b");
892 assert!(!b_node.cycle);
893 assert_eq!(b_node.children.len(), 1);
894 let cycle_node = &b_node.children[0];
895 assert_eq!(cycle_node.task.id, "a");
896 assert!(cycle_node.cycle);
897 assert!(cycle_node.children.is_empty());
898 }
899
900 #[test]
901 fn test_dep_tree_transitive_cycle() {
902 let tasks = make_tasks(vec![
904 make_task("a", Status::Open, Priority::P1, vec!["b"]),
905 make_task("b", Status::Open, Priority::P1, vec!["c"]),
906 make_task("c", Status::Open, Priority::P1, vec!["a"]),
907 ]);
908 let graph = Graph::build(&tasks);
909 let tree = graph.dep_tree(&tasks, "a").unwrap();
910 assert!(!tree.cycle);
911 let b = &tree.children[0];
912 let c = &b.children[0];
913 assert_eq!(c.task.id, "c");
914 assert!(!c.cycle);
915 let back_to_a = &c.children[0];
916 assert_eq!(back_to_a.task.id, "a");
917 assert!(back_to_a.cycle);
918 assert!(back_to_a.children.is_empty());
919 }
920
921 #[test]
922 fn test_dep_tree_self_cycle() {
923 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec!["a"])]);
925 let graph = Graph::build(&tasks);
926 let tree = graph.dep_tree(&tasks, "a").unwrap();
927 assert!(!tree.cycle);
928 assert_eq!(tree.children.len(), 1);
929 let self_ref = &tree.children[0];
930 assert_eq!(self_ref.task.id, "a");
931 assert!(self_ref.cycle);
932 assert!(self_ref.children.is_empty());
933 }
934
935 #[test]
936 fn test_dep_tree_no_cycle() {
937 let tasks = make_tasks(vec![
941 make_task("a", Status::Open, Priority::P1, vec!["b", "c"]),
942 make_task("b", Status::Open, Priority::P1, vec!["d"]),
943 make_task("c", Status::Open, Priority::P1, vec!["d"]),
944 make_task("d", Status::Open, Priority::P1, vec![]),
945 ]);
946 let graph = Graph::build(&tasks);
947 let tree = graph.dep_tree(&tasks, "a").unwrap();
948 assert!(!tree.cycle);
949 assert!(!tree.seen);
950 assert_eq!(tree.children.len(), 2);
952 for child in &tree.children {
953 assert!(!child.cycle);
954 assert_eq!(child.children.len(), 1);
956 let d_node = &child.children[0];
957 assert_eq!(d_node.task.id, "d");
958 assert!(!d_node.cycle);
959 }
961 let d_nodes: Vec<_> = tree
963 .children
964 .iter()
965 .flat_map(|c| c.children.iter())
966 .collect();
967 assert_eq!(d_nodes.len(), 2);
968 let seen_count = d_nodes.iter().filter(|n| n.seen).count();
969 let full_count = d_nodes.iter().filter(|n| !n.seen && !n.cycle).count();
970 assert_eq!(
971 seen_count, 1,
972 "exactly one d occurrence should be a seen-ref"
973 );
974 assert_eq!(
975 full_count, 1,
976 "exactly one d occurrence should be fully expanded"
977 );
978 }
979
980 #[test]
981 fn test_effective_priority_no_dependents() {
982 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P3, vec![])]);
984 let graph = Graph::build(&tasks);
985 assert_eq!(graph.effective_priority("a", &tasks), Priority::P3);
986 }
987
988 #[test]
989 fn test_effective_priority_single_dependent() {
990 let tasks = make_tasks(vec![
993 make_task("a", Status::Open, Priority::P3, vec![]),
994 make_task("b", Status::Open, Priority::P1, vec!["a"]),
995 ]);
996 let graph = Graph::build(&tasks);
997 assert_eq!(graph.effective_priority("a", &tasks), Priority::P1);
998 assert_eq!(graph.effective_priority("b", &tasks), Priority::P1);
1000 }
1001
1002 #[test]
1003 fn test_effective_priority_chain() {
1004 let tasks = make_tasks(vec![
1007 make_task("a", Status::Open, Priority::P3, vec![]),
1008 make_task("b", Status::Open, Priority::P2, vec!["a"]),
1009 make_task("c", Status::Open, Priority::P0, vec!["b"]),
1010 ]);
1011 let graph = Graph::build(&tasks);
1012 assert_eq!(graph.effective_priority("a", &tasks), Priority::P0);
1013 assert_eq!(graph.effective_priority("b", &tasks), Priority::P0);
1014 assert_eq!(graph.effective_priority("c", &tasks), Priority::P0);
1015 }
1016
1017 #[test]
1018 fn test_effective_priority_diamond() {
1019 let tasks = make_tasks(vec![
1022 make_task("a", Status::Open, Priority::P3, vec![]),
1023 make_task("b", Status::Open, Priority::P2, vec!["a"]),
1024 make_task("c", Status::Open, Priority::P3, vec!["a"]),
1025 make_task("d", Status::Open, Priority::P0, vec!["b", "c"]),
1026 ]);
1027 let graph = Graph::build(&tasks);
1028 assert_eq!(graph.effective_priority("a", &tasks), Priority::P0);
1029 assert_eq!(graph.effective_priority("b", &tasks), Priority::P0);
1030 assert_eq!(graph.effective_priority("c", &tasks), Priority::P0);
1031 }
1032
1033 #[test]
1034 fn test_effective_priority_own_is_highest() {
1035 let tasks = make_tasks(vec![
1037 make_task("a", Status::Open, Priority::P0, vec![]),
1038 make_task("b", Status::Open, Priority::P3, vec!["a"]),
1039 ]);
1040 let graph = Graph::build(&tasks);
1041 assert_eq!(graph.effective_priority("a", &tasks), Priority::P0);
1042 }
1043
1044 #[test]
1045 fn test_topo_sort_linear_chain() {
1046 let tasks = make_tasks(vec![
1048 make_task("a", Status::Open, Priority::P1, vec![]),
1049 make_task("b", Status::Open, Priority::P1, vec!["a"]),
1050 make_task("c", Status::Open, Priority::P1, vec!["b"]),
1051 ]);
1052 let graph = Graph::build(&tasks);
1053 let subset: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1054 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1055 let ids: Vec<&str> = sorted.iter().map(|t| t.id.as_str()).collect();
1056 assert_eq!(ids, vec!["a", "b", "c"]);
1057 }
1058
1059 #[test]
1060 fn test_topo_sort_diamond() {
1061 let tasks = make_tasks(vec![
1063 make_task("a", Status::Open, Priority::P1, vec!["b", "c"]),
1064 make_task("b", Status::Open, Priority::P1, vec!["d"]),
1065 make_task("c", Status::Open, Priority::P1, vec!["d"]),
1066 make_task("d", Status::Open, Priority::P1, vec![]),
1067 ]);
1068 let graph = Graph::build(&tasks);
1069 let subset: HashSet<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
1070 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1071 let ids: Vec<&str> = sorted.iter().map(|t| t.id.as_str()).collect();
1072 assert_eq!(ids[0], "d");
1074 assert_eq!(ids[ids.len() - 1], "a");
1075 }
1076
1077 #[test]
1078 fn test_topo_sort_independent() {
1079 let tasks = make_tasks(vec![
1081 make_task("a", Status::Open, Priority::P2, vec![]),
1082 make_task("b", Status::Open, Priority::P0, vec![]),
1083 make_task("c", Status::Open, Priority::P1, vec![]),
1084 ]);
1085 let graph = Graph::build(&tasks);
1086 let subset: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1087 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1088 let ids: Vec<&str> = sorted.iter().map(|t| t.id.as_str()).collect();
1089 assert_eq!(ids, vec!["b", "c", "a"]);
1091 }
1092
1093 #[test]
1094 fn test_topo_sort_single_task() {
1095 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
1096 let graph = Graph::build(&tasks);
1097 let subset: HashSet<String> = ["a"].iter().map(|s| s.to_string()).collect();
1098 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1099 assert_eq!(sorted.len(), 1);
1100 assert_eq!(sorted[0].id, "a");
1101 }
1102
1103 #[test]
1104 fn test_topo_sort_empty() {
1105 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
1106 let graph = Graph::build(&tasks);
1107 let subset: HashSet<String> = HashSet::new();
1108 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1109 assert!(sorted.is_empty());
1110 }
1111
1112 #[test]
1113 fn test_topo_sort_reports_cyclic_tasks() {
1114 let tasks = make_tasks(vec![
1116 make_task("a", Status::Open, Priority::P1, vec!["b"]),
1117 make_task("b", Status::Open, Priority::P1, vec!["a"]),
1118 make_task("c", Status::Open, Priority::P1, vec![]),
1119 ]);
1120 let graph = Graph::build(&tasks);
1121 let subset: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1122 let topo = graph.topo_sort_subset(&subset, &tasks);
1123
1124 let sorted_ids: Vec<&str> = topo.sorted.iter().map(|t| t.id.as_str()).collect();
1125 assert_eq!(sorted_ids, vec!["c"]);
1126
1127 let mut cyclic_ids: Vec<&str> = topo.cyclic.iter().map(|t| t.id.as_str()).collect();
1128 cyclic_ids.sort();
1129 assert_eq!(cyclic_ids, vec!["a", "b"]);
1130 }
1131
1132 #[test]
1133 fn test_topo_sort_ignores_external_deps() {
1134 let tasks = make_tasks(vec![
1136 make_task("a", Status::Open, Priority::P1, vec![]),
1137 make_task("b", Status::Open, Priority::P1, vec!["ext"]),
1138 make_task("ext", Status::Done, Priority::P1, vec![]),
1139 ]);
1140 let graph = Graph::build(&tasks);
1141 let subset: HashSet<String> = ["a", "b"].iter().map(|s| s.to_string()).collect();
1142 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1143 assert_eq!(sorted.len(), 2);
1144 }
1145
1146 fn count_nodes(node: &DepNode<'_>) -> usize {
1157 1 + node.children.iter().map(count_nodes).sum::<usize>()
1158 }
1159
1160 #[test]
1165 fn test_effective_priority_long_chain_correct() {
1166 let n = 20usize;
1170 let mut task_list = Vec::new();
1171 for i in 0..n {
1172 let priority = if i == n - 1 {
1173 Priority::P0
1174 } else {
1175 Priority::P3
1176 };
1177 task_list.push(make_task(&format!("t{i}"), Status::Open, priority, vec![]));
1178 }
1179 for i in 0..n - 1 {
1181 task_list[i + 1].depends_on = vec![format!("t{i}")];
1182 }
1183 let tasks = make_tasks(task_list);
1184 let graph = Graph::build(&tasks);
1185
1186 let eff = graph.effective_priorities_all(&tasks);
1188 assert_eq!(
1189 eff["t0"],
1190 Priority::P0,
1191 "t0 effective priority should be P0"
1192 );
1193 assert_eq!(
1194 eff["t9"],
1195 Priority::P0,
1196 "t9 effective priority should be P0"
1197 );
1198 assert_eq!(eff["t19"], Priority::P0);
1200 }
1201
1202 #[test]
1203 fn test_dep_tree_deep_diamond_linear_node_count() {
1204 let tasks = make_tasks(vec![
1220 make_task("root", Status::Open, Priority::P1, vec!["l1a", "l1b"]),
1221 make_task("l1a", Status::Open, Priority::P1, vec!["l2a", "l2b"]),
1222 make_task("l1b", Status::Open, Priority::P1, vec!["l2a", "l2b"]),
1223 make_task("l2a", Status::Open, Priority::P1, vec!["leaf"]),
1224 make_task("l2b", Status::Open, Priority::P1, vec!["leaf"]),
1225 make_task("leaf", Status::Open, Priority::P1, vec![]),
1226 ]);
1227 let graph = Graph::build(&tasks);
1228 let tree = graph.dep_tree(&tasks, "root").unwrap();
1229
1230 let node_count = count_nodes(&tree);
1235 let v = tasks.len(); let e: usize = tasks.values().map(|t| t.depends_on.len()).sum(); assert!(
1238 node_count <= v + e,
1239 "node_count={node_count} exceeded V+E={} — exponential blowup detected",
1240 v + e
1241 );
1242 }
1243
1244 fn make_dense_tasks(n: usize) -> HashMap<String, Task> {
1253 let mut list = Vec::with_capacity(n + 1);
1254 list.push(make_task("bottom", Status::Done, Priority::P2, vec![]));
1256 for i in 0..n {
1257 let id = format!("t{i:04}");
1258 let priority = if i % 10 == 0 {
1259 Priority::P0
1260 } else {
1261 Priority::P3
1262 };
1263 list.push(make_task(&id, Status::Open, priority, vec![]));
1264 }
1265 let mut map: HashMap<String, Task> = list.into_iter().map(|t| (t.id.clone(), t)).collect();
1267 for i in 0..n {
1268 let id = format!("t{i:04}");
1269 let mut deps = vec!["bottom".to_string()];
1270 if i > 0 {
1271 deps.push(format!("t{:04}", i - 1));
1272 }
1273 map.get_mut(&id).unwrap().depends_on = deps;
1274 }
1275 map
1276 }
1277
1278 #[test]
1279 #[ignore]
1280 fn bench_effective_priorities_large_graph() {
1281 let n = 500;
1284 let tasks = make_dense_tasks(n);
1285 let graph = Graph::build(&tasks);
1286
1287 let start = std::time::Instant::now();
1288 let eff = graph.effective_priorities_all(&tasks);
1289 let elapsed = start.elapsed();
1290
1291 assert_eq!(eff.len(), n + 1); assert!(
1293 elapsed.as_millis() < 500,
1294 "effective_priorities_all on {n} tasks took {}ms (expected <500ms)",
1295 elapsed.as_millis()
1296 );
1297 }
1298
1299 #[test]
1300 #[ignore]
1301 fn bench_ready_large_graph() {
1302 let n = 500;
1305 let tasks = make_dense_tasks(n);
1306 let graph = Graph::build(&tasks);
1307
1308 let start = std::time::Instant::now();
1309 let ready = graph.ready(&tasks, None, None, None);
1310 let elapsed = start.elapsed();
1311
1312 assert!(
1314 ready.iter().any(|t| t.id == "t0000"),
1315 "t0000 should be in the ready list"
1316 );
1317 assert!(
1318 elapsed.as_millis() < 500,
1319 "graph.ready on {n} tasks took {}ms (expected <500ms)",
1320 elapsed.as_millis()
1321 );
1322 }
1323
1324 #[test]
1325 #[ignore]
1326 fn bench_dep_tree_diamond_deep() {
1327 let layers = 6usize;
1331 let mut map: HashMap<String, Task> = HashMap::new();
1337 let root = make_task("root", Status::Open, Priority::P1, vec![]);
1338 map.insert("root".to_string(), root);
1339
1340 let mut layer_ids: Vec<Vec<String>> = Vec::new();
1341 layer_ids.push(vec!["root".to_string()]);
1343 for l in 1..layers {
1345 let count = if l == layers - 1 {
1346 1
1347 } else {
1348 2usize.pow(l as u32)
1349 };
1350 let ids: Vec<String> = (0..count).map(|i| format!("l{l}_{i}")).collect();
1351 layer_ids.push(ids);
1352 }
1353
1354 for ids in &layer_ids {
1356 for id in ids {
1357 let t = make_task(id, Status::Open, Priority::P1, vec![]);
1358 map.insert(id.clone(), t);
1359 }
1360 }
1361 for l in 0..layers - 1 {
1363 let next = layer_ids[l + 1].clone();
1364 for id in &layer_ids[l] {
1365 map.get_mut(id).unwrap().depends_on = next.clone();
1366 }
1367 }
1368
1369 let graph = Graph::build(&map);
1370 let start = std::time::Instant::now();
1371 let tree = graph.dep_tree(&map, "root").unwrap();
1372 let elapsed = start.elapsed();
1373
1374 let node_count = count_nodes(&tree);
1375 let v = map.len();
1376 let e: usize = map.values().map(|t| t.depends_on.len()).sum();
1377 assert!(
1378 node_count <= v + e,
1379 "node_count={node_count} exceeded V+E={} — exponential blowup",
1380 v + e
1381 );
1382 assert!(
1383 elapsed.as_millis() < 500,
1384 "dep_tree on {layers}-layer diamond took {}ms (expected <500ms)",
1385 elapsed.as_millis()
1386 );
1387 }
1388}