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 body: String::new(),
551 }
552 }
553
554 fn make_tasks(tasks: Vec<Task>) -> HashMap<String, Task> {
555 tasks.into_iter().map(|t| (t.id.clone(), t)).collect()
556 }
557
558 #[test]
559 fn test_ready_no_deps() {
560 let tasks = make_tasks(vec![
561 make_task("a", Status::Open, Priority::P1, vec![]),
562 make_task("b", Status::Open, Priority::P0, vec![]),
563 ]);
564 let graph = Graph::build(&tasks);
565 let ready = graph.ready(&tasks, None, None, None);
566 assert_eq!(ready.len(), 2);
568 assert_eq!(ready[0].id, "b");
569 assert_eq!(ready[1].id, "a");
570 }
571
572 #[test]
573 fn test_ready_with_deps() {
574 let tasks = make_tasks(vec![
575 make_task("a", Status::Done, Priority::P1, vec![]),
576 make_task("b", Status::Open, Priority::P1, vec!["a"]),
577 make_task("c", Status::Open, Priority::P1, vec!["b"]),
578 ]);
579 let graph = Graph::build(&tasks);
580 let ready = graph.ready(&tasks, None, None, None);
581 assert_eq!(ready.len(), 1);
583 assert_eq!(ready[0].id, "b");
584 }
585
586 #[test]
587 fn test_ready_blocked_by_undone_dep() {
588 let tasks = make_tasks(vec![
589 make_task("a", Status::InProgress, Priority::P1, vec![]),
590 make_task("b", Status::Open, Priority::P1, vec!["a"]),
591 ]);
592 let graph = Graph::build(&tasks);
593 let ready = graph.ready(&tasks, None, None, None);
594 assert!(ready.is_empty());
595 }
596
597 #[test]
598 fn test_ready_blocked_by_missing_dep() {
599 let tasks = make_tasks(vec![make_task(
601 "b",
602 Status::Open,
603 Priority::P1,
604 vec!["nonexistent"],
605 )]);
606 let graph = Graph::build(&tasks);
607 let ready = graph.ready(&tasks, None, None, None);
608 assert!(ready.is_empty());
609 }
610
611 #[test]
612 fn test_ready_with_tag_filter() {
613 let mut t = make_task("a", Status::Open, Priority::P1, vec![]);
614 t.tags = vec!["backend".into()];
615 let tasks = make_tasks(vec![t, make_task("b", Status::Open, Priority::P1, vec![])]);
616 let graph = Graph::build(&tasks);
617
618 let ready = graph.ready(&tasks, Some("backend"), None, None);
619 assert_eq!(ready.len(), 1);
620 assert_eq!(ready[0].id, "a");
621 }
622
623 #[test]
624 fn test_ready_with_limit() {
625 let tasks = make_tasks(vec![
626 make_task("a", Status::Open, Priority::P1, vec![]),
627 make_task("b", Status::Open, Priority::P1, vec![]),
628 make_task("c", Status::Open, Priority::P1, vec![]),
629 ]);
630 let graph = Graph::build(&tasks);
631 let ready = graph.ready(&tasks, None, Some(2), None);
632 assert_eq!(ready.len(), 2);
633 }
634
635 #[test]
636 fn test_ready_excludes_epics() {
637 let mut epic = make_task("e", Status::Open, Priority::P0, vec![]);
638 epic.task_type = TaskType::Epic;
639 let tasks = make_tasks(vec![
640 epic,
641 make_task("a", Status::Open, Priority::P1, vec![]),
642 ]);
643 let graph = Graph::build(&tasks);
644 let ready = graph.ready(&tasks, None, None, None);
645 assert_eq!(ready.len(), 1);
647 assert_eq!(ready[0].id, "a");
648 }
649
650 #[test]
651 fn test_ready_with_epic_filter() {
652 let mut t1 = make_task("a", Status::Open, Priority::P1, vec![]);
653 t1.parent = Some("epic1".into());
654 let mut t2 = make_task("b", Status::Open, Priority::P1, vec![]);
655 t2.parent = Some("epic2".into());
656 let t3 = make_task("c", Status::Open, Priority::P1, vec![]);
657 let tasks = make_tasks(vec![t1, t2, t3]);
658 let graph = Graph::build(&tasks);
659
660 let ready = graph.ready(&tasks, None, None, Some("epic1"));
662 assert_eq!(ready.len(), 1);
663 assert_eq!(ready[0].id, "a");
664
665 let ready = graph.ready(&tasks, None, None, Some("epic2"));
667 assert_eq!(ready.len(), 1);
668 assert_eq!(ready[0].id, "b");
669
670 let ready = graph.ready(&tasks, None, None, None);
672 assert_eq!(ready.len(), 3);
673 }
674
675 #[test]
678 fn test_is_task_ready_no_deps() {
679 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
680 assert!(is_task_ready(&tasks, tasks.get("a").unwrap()));
681 }
682
683 #[test]
684 fn test_is_task_ready_dep_done() {
685 let tasks = make_tasks(vec![
686 make_task("dep", Status::Done, Priority::P1, vec![]),
687 make_task("a", Status::Open, Priority::P1, vec!["dep"]),
688 ]);
689 assert!(is_task_ready(&tasks, tasks.get("a").unwrap()));
690 }
691
692 #[test]
693 fn test_is_task_ready_dep_not_done() {
694 let tasks = make_tasks(vec![
695 make_task("dep", Status::Open, Priority::P1, vec![]),
696 make_task("a", Status::Open, Priority::P1, vec!["dep"]),
697 ]);
698 assert!(!is_task_ready(&tasks, tasks.get("a").unwrap()));
699 }
700
701 #[test]
702 fn test_is_task_ready_missing_dep_blocks() {
703 let tasks = make_tasks(vec![make_task(
705 "a",
706 Status::Open,
707 Priority::P1,
708 vec!["nonexistent"],
709 )]);
710 assert!(!is_task_ready(&tasks, tasks.get("a").unwrap()));
711 }
712
713 #[test]
714 fn test_is_task_ready_excludes_epics() {
715 let mut epic = make_task("e", Status::Open, Priority::P0, vec![]);
716 epic.task_type = TaskType::Epic;
717 let tasks = make_tasks(vec![epic]);
718 assert!(!is_task_ready(&tasks, tasks.get("e").unwrap()));
719 }
720
721 #[test]
722 fn test_is_task_ready_excludes_non_open() {
723 let tasks = make_tasks(vec![make_task(
724 "a",
725 Status::InProgress,
726 Priority::P1,
727 vec![],
728 )]);
729 assert!(!is_task_ready(&tasks, tasks.get("a").unwrap()));
730 }
731
732 #[test]
735 fn test_ready_and_is_task_ready_agree() {
736 let tasks = make_tasks(vec![
737 make_task("t1", Status::Open, Priority::P1, vec![]),
739 make_task("t2", Status::Open, Priority::P1, vec!["t1"]),
741 make_task("t3", Status::Open, Priority::P1, vec!["t4"]),
743 make_task("t4", Status::Done, Priority::P1, vec![]),
745 make_task("t5", Status::Open, Priority::P1, vec!["ghost"]),
747 ]);
748 let graph = Graph::build(&tasks);
749 let ready_ids: std::collections::HashSet<&str> = graph
750 .ready(&tasks, None, None, None)
751 .iter()
752 .map(|t| t.id.as_str())
753 .collect();
754
755 for task in tasks.values() {
756 let predicate = is_task_ready(&tasks, task);
757 let in_ready = ready_ids.contains(task.id.as_str());
758 assert_eq!(
759 predicate, in_ready,
760 "is_task_ready and graph.ready disagree on task '{}'",
761 task.id
762 );
763 }
764 }
765
766 #[test]
767 fn test_cycle_self() {
768 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
769 let graph = Graph::build(&tasks);
770 assert!(graph.would_cycle("a", "a"));
771 }
772
773 #[test]
774 fn test_cycle_direct() {
775 let tasks = make_tasks(vec![
777 make_task("a", Status::Open, Priority::P1, vec!["b"]),
778 make_task("b", Status::Open, Priority::P1, vec![]),
779 ]);
780 let graph = Graph::build(&tasks);
781 assert!(graph.would_cycle("b", "a"));
782 }
783
784 #[test]
785 fn test_cycle_transitive() {
786 let tasks = make_tasks(vec![
788 make_task("a", Status::Open, Priority::P1, vec!["b"]),
789 make_task("b", Status::Open, Priority::P1, vec!["c"]),
790 make_task("c", Status::Open, Priority::P1, vec![]),
791 ]);
792 let graph = Graph::build(&tasks);
793 assert!(graph.would_cycle("c", "a"));
794 }
795
796 #[test]
797 fn test_no_cycle() {
798 let tasks = make_tasks(vec![
799 make_task("a", Status::Open, Priority::P1, vec![]),
800 make_task("b", Status::Open, Priority::P1, vec![]),
801 ]);
802 let graph = Graph::build(&tasks);
803 assert!(!graph.would_cycle("a", "b"));
804 }
805
806 #[test]
807 fn test_dep_tree() {
808 let tasks = make_tasks(vec![
809 make_task("a", Status::Open, Priority::P1, vec!["b", "c"]),
810 make_task("b", Status::Open, Priority::P1, vec![]),
811 make_task("c", Status::Open, Priority::P1, vec![]),
812 ]);
813 let graph = Graph::build(&tasks);
814 let tree = graph.dep_tree(&tasks, "a").unwrap();
815 assert_eq!(tree.task.id, "a");
816 assert_eq!(tree.children.len(), 2);
817 }
818
819 #[test]
820 fn test_empty_graph() {
821 let tasks: HashMap<String, Task> = HashMap::new();
822 let graph = Graph::build(&tasks);
823 let ready = graph.ready(&tasks, None, None, None);
824 assert!(ready.is_empty());
825 }
826
827 #[test]
828 fn test_adjacency_list() {
829 let tasks = make_tasks(vec![
830 make_task("a", Status::Open, Priority::P1, vec!["b"]),
831 make_task("b", Status::Open, Priority::P1, vec![]),
832 ]);
833 let graph = Graph::build(&tasks);
834 let adj = graph.adjacency_list();
835 assert_eq!(adj.get("a").unwrap().len(), 1);
836 assert!(adj.get("b").unwrap().is_empty());
837 }
838
839 #[test]
840 fn test_bounded_adjacency_list_excludes_done_and_isolated() {
841 let tasks = make_tasks(vec![
846 make_task("a", Status::Open, Priority::P1, vec!["b"]),
847 make_task("b", Status::Open, Priority::P1, vec![]),
848 make_task("c", Status::Done, Priority::P1, vec![]),
849 make_task("d", Status::Open, Priority::P1, vec![]),
850 ]);
851 let graph = Graph::build(&tasks);
852
853 let adj = graph.bounded_adjacency_list(&tasks, false, None, None);
855 assert!(adj.contains_key("a"), "connected open node a should appear");
856 assert!(adj.contains_key("b"), "connected open node b should appear");
857 assert!(!adj.contains_key("c"), "done node c should be excluded");
858 assert!(
859 !adj.contains_key("d"),
860 "isolated open node d should be excluded"
861 );
862
863 let adj_all = graph.bounded_adjacency_list(&tasks, true, None, None);
865 assert!(adj_all.contains_key("a"));
866 assert!(adj_all.contains_key("b"));
867 assert!(
868 !adj_all.contains_key("c"),
869 "isolated done node c excluded even with include_done=true"
870 );
871 assert!(
872 !adj_all.contains_key("d"),
873 "isolated open node d still excluded with include_done=true"
874 );
875 }
876
877 #[test]
878 fn test_dep_tree_direct_cycle() {
879 let tasks = make_tasks(vec![
881 make_task("a", Status::Open, Priority::P1, vec!["b"]),
882 make_task("b", Status::Open, Priority::P1, vec!["a"]),
883 ]);
884 let graph = Graph::build(&tasks);
885 let tree = graph.dep_tree(&tasks, "a").unwrap();
886 assert!(!tree.cycle);
887 assert_eq!(tree.children.len(), 1);
888 let b_node = &tree.children[0];
890 assert_eq!(b_node.task.id, "b");
891 assert!(!b_node.cycle);
892 assert_eq!(b_node.children.len(), 1);
893 let cycle_node = &b_node.children[0];
894 assert_eq!(cycle_node.task.id, "a");
895 assert!(cycle_node.cycle);
896 assert!(cycle_node.children.is_empty());
897 }
898
899 #[test]
900 fn test_dep_tree_transitive_cycle() {
901 let tasks = make_tasks(vec![
903 make_task("a", Status::Open, Priority::P1, vec!["b"]),
904 make_task("b", Status::Open, Priority::P1, vec!["c"]),
905 make_task("c", Status::Open, Priority::P1, vec!["a"]),
906 ]);
907 let graph = Graph::build(&tasks);
908 let tree = graph.dep_tree(&tasks, "a").unwrap();
909 assert!(!tree.cycle);
910 let b = &tree.children[0];
911 let c = &b.children[0];
912 assert_eq!(c.task.id, "c");
913 assert!(!c.cycle);
914 let back_to_a = &c.children[0];
915 assert_eq!(back_to_a.task.id, "a");
916 assert!(back_to_a.cycle);
917 assert!(back_to_a.children.is_empty());
918 }
919
920 #[test]
921 fn test_dep_tree_self_cycle() {
922 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec!["a"])]);
924 let graph = Graph::build(&tasks);
925 let tree = graph.dep_tree(&tasks, "a").unwrap();
926 assert!(!tree.cycle);
927 assert_eq!(tree.children.len(), 1);
928 let self_ref = &tree.children[0];
929 assert_eq!(self_ref.task.id, "a");
930 assert!(self_ref.cycle);
931 assert!(self_ref.children.is_empty());
932 }
933
934 #[test]
935 fn test_dep_tree_no_cycle() {
936 let tasks = make_tasks(vec![
940 make_task("a", Status::Open, Priority::P1, vec!["b", "c"]),
941 make_task("b", Status::Open, Priority::P1, vec!["d"]),
942 make_task("c", Status::Open, Priority::P1, vec!["d"]),
943 make_task("d", Status::Open, Priority::P1, vec![]),
944 ]);
945 let graph = Graph::build(&tasks);
946 let tree = graph.dep_tree(&tasks, "a").unwrap();
947 assert!(!tree.cycle);
948 assert!(!tree.seen);
949 assert_eq!(tree.children.len(), 2);
951 for child in &tree.children {
952 assert!(!child.cycle);
953 assert_eq!(child.children.len(), 1);
955 let d_node = &child.children[0];
956 assert_eq!(d_node.task.id, "d");
957 assert!(!d_node.cycle);
958 }
960 let d_nodes: Vec<_> = tree
962 .children
963 .iter()
964 .flat_map(|c| c.children.iter())
965 .collect();
966 assert_eq!(d_nodes.len(), 2);
967 let seen_count = d_nodes.iter().filter(|n| n.seen).count();
968 let full_count = d_nodes.iter().filter(|n| !n.seen && !n.cycle).count();
969 assert_eq!(
970 seen_count, 1,
971 "exactly one d occurrence should be a seen-ref"
972 );
973 assert_eq!(
974 full_count, 1,
975 "exactly one d occurrence should be fully expanded"
976 );
977 }
978
979 #[test]
980 fn test_effective_priority_no_dependents() {
981 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P3, vec![])]);
983 let graph = Graph::build(&tasks);
984 assert_eq!(graph.effective_priority("a", &tasks), Priority::P3);
985 }
986
987 #[test]
988 fn test_effective_priority_single_dependent() {
989 let tasks = make_tasks(vec![
992 make_task("a", Status::Open, Priority::P3, vec![]),
993 make_task("b", Status::Open, Priority::P1, vec!["a"]),
994 ]);
995 let graph = Graph::build(&tasks);
996 assert_eq!(graph.effective_priority("a", &tasks), Priority::P1);
997 assert_eq!(graph.effective_priority("b", &tasks), Priority::P1);
999 }
1000
1001 #[test]
1002 fn test_effective_priority_chain() {
1003 let tasks = make_tasks(vec![
1006 make_task("a", Status::Open, Priority::P3, vec![]),
1007 make_task("b", Status::Open, Priority::P2, vec!["a"]),
1008 make_task("c", Status::Open, Priority::P0, vec!["b"]),
1009 ]);
1010 let graph = Graph::build(&tasks);
1011 assert_eq!(graph.effective_priority("a", &tasks), Priority::P0);
1012 assert_eq!(graph.effective_priority("b", &tasks), Priority::P0);
1013 assert_eq!(graph.effective_priority("c", &tasks), Priority::P0);
1014 }
1015
1016 #[test]
1017 fn test_effective_priority_diamond() {
1018 let tasks = make_tasks(vec![
1021 make_task("a", Status::Open, Priority::P3, vec![]),
1022 make_task("b", Status::Open, Priority::P2, vec!["a"]),
1023 make_task("c", Status::Open, Priority::P3, vec!["a"]),
1024 make_task("d", Status::Open, Priority::P0, vec!["b", "c"]),
1025 ]);
1026 let graph = Graph::build(&tasks);
1027 assert_eq!(graph.effective_priority("a", &tasks), Priority::P0);
1028 assert_eq!(graph.effective_priority("b", &tasks), Priority::P0);
1029 assert_eq!(graph.effective_priority("c", &tasks), Priority::P0);
1030 }
1031
1032 #[test]
1033 fn test_effective_priority_own_is_highest() {
1034 let tasks = make_tasks(vec![
1036 make_task("a", Status::Open, Priority::P0, vec![]),
1037 make_task("b", Status::Open, Priority::P3, vec!["a"]),
1038 ]);
1039 let graph = Graph::build(&tasks);
1040 assert_eq!(graph.effective_priority("a", &tasks), Priority::P0);
1041 }
1042
1043 #[test]
1044 fn test_topo_sort_linear_chain() {
1045 let tasks = make_tasks(vec![
1047 make_task("a", Status::Open, Priority::P1, vec![]),
1048 make_task("b", Status::Open, Priority::P1, vec!["a"]),
1049 make_task("c", Status::Open, Priority::P1, vec!["b"]),
1050 ]);
1051 let graph = Graph::build(&tasks);
1052 let subset: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1053 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1054 let ids: Vec<&str> = sorted.iter().map(|t| t.id.as_str()).collect();
1055 assert_eq!(ids, vec!["a", "b", "c"]);
1056 }
1057
1058 #[test]
1059 fn test_topo_sort_diamond() {
1060 let tasks = make_tasks(vec![
1062 make_task("a", Status::Open, Priority::P1, vec!["b", "c"]),
1063 make_task("b", Status::Open, Priority::P1, vec!["d"]),
1064 make_task("c", Status::Open, Priority::P1, vec!["d"]),
1065 make_task("d", Status::Open, Priority::P1, vec![]),
1066 ]);
1067 let graph = Graph::build(&tasks);
1068 let subset: HashSet<String> = ["a", "b", "c", "d"].iter().map(|s| s.to_string()).collect();
1069 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1070 let ids: Vec<&str> = sorted.iter().map(|t| t.id.as_str()).collect();
1071 assert_eq!(ids[0], "d");
1073 assert_eq!(ids[ids.len() - 1], "a");
1074 }
1075
1076 #[test]
1077 fn test_topo_sort_independent() {
1078 let tasks = make_tasks(vec![
1080 make_task("a", Status::Open, Priority::P2, vec![]),
1081 make_task("b", Status::Open, Priority::P0, vec![]),
1082 make_task("c", Status::Open, Priority::P1, vec![]),
1083 ]);
1084 let graph = Graph::build(&tasks);
1085 let subset: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1086 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1087 let ids: Vec<&str> = sorted.iter().map(|t| t.id.as_str()).collect();
1088 assert_eq!(ids, vec!["b", "c", "a"]);
1090 }
1091
1092 #[test]
1093 fn test_topo_sort_single_task() {
1094 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
1095 let graph = Graph::build(&tasks);
1096 let subset: HashSet<String> = ["a"].iter().map(|s| s.to_string()).collect();
1097 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1098 assert_eq!(sorted.len(), 1);
1099 assert_eq!(sorted[0].id, "a");
1100 }
1101
1102 #[test]
1103 fn test_topo_sort_empty() {
1104 let tasks = make_tasks(vec![make_task("a", Status::Open, Priority::P1, vec![])]);
1105 let graph = Graph::build(&tasks);
1106 let subset: HashSet<String> = HashSet::new();
1107 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1108 assert!(sorted.is_empty());
1109 }
1110
1111 #[test]
1112 fn test_topo_sort_reports_cyclic_tasks() {
1113 let tasks = make_tasks(vec![
1115 make_task("a", Status::Open, Priority::P1, vec!["b"]),
1116 make_task("b", Status::Open, Priority::P1, vec!["a"]),
1117 make_task("c", Status::Open, Priority::P1, vec![]),
1118 ]);
1119 let graph = Graph::build(&tasks);
1120 let subset: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
1121 let topo = graph.topo_sort_subset(&subset, &tasks);
1122
1123 let sorted_ids: Vec<&str> = topo.sorted.iter().map(|t| t.id.as_str()).collect();
1124 assert_eq!(sorted_ids, vec!["c"]);
1125
1126 let mut cyclic_ids: Vec<&str> = topo.cyclic.iter().map(|t| t.id.as_str()).collect();
1127 cyclic_ids.sort();
1128 assert_eq!(cyclic_ids, vec!["a", "b"]);
1129 }
1130
1131 #[test]
1132 fn test_topo_sort_ignores_external_deps() {
1133 let tasks = make_tasks(vec![
1135 make_task("a", Status::Open, Priority::P1, vec![]),
1136 make_task("b", Status::Open, Priority::P1, vec!["ext"]),
1137 make_task("ext", Status::Done, Priority::P1, vec![]),
1138 ]);
1139 let graph = Graph::build(&tasks);
1140 let subset: HashSet<String> = ["a", "b"].iter().map(|s| s.to_string()).collect();
1141 let sorted = graph.topo_sort_subset(&subset, &tasks).sorted;
1142 assert_eq!(sorted.len(), 2);
1143 }
1144
1145 fn count_nodes(node: &DepNode<'_>) -> usize {
1156 1 + node.children.iter().map(count_nodes).sum::<usize>()
1157 }
1158
1159 #[test]
1164 fn test_effective_priority_long_chain_correct() {
1165 let n = 20usize;
1169 let mut task_list = Vec::new();
1170 for i in 0..n {
1171 let priority = if i == n - 1 {
1172 Priority::P0
1173 } else {
1174 Priority::P3
1175 };
1176 task_list.push(make_task(&format!("t{i}"), Status::Open, priority, vec![]));
1177 }
1178 for i in 0..n - 1 {
1180 task_list[i + 1].depends_on = vec![format!("t{i}")];
1181 }
1182 let tasks = make_tasks(task_list);
1183 let graph = Graph::build(&tasks);
1184
1185 let eff = graph.effective_priorities_all(&tasks);
1187 assert_eq!(
1188 eff["t0"],
1189 Priority::P0,
1190 "t0 effective priority should be P0"
1191 );
1192 assert_eq!(
1193 eff["t9"],
1194 Priority::P0,
1195 "t9 effective priority should be P0"
1196 );
1197 assert_eq!(eff["t19"], Priority::P0);
1199 }
1200
1201 #[test]
1202 fn test_dep_tree_deep_diamond_linear_node_count() {
1203 let tasks = make_tasks(vec![
1219 make_task("root", Status::Open, Priority::P1, vec!["l1a", "l1b"]),
1220 make_task("l1a", Status::Open, Priority::P1, vec!["l2a", "l2b"]),
1221 make_task("l1b", Status::Open, Priority::P1, vec!["l2a", "l2b"]),
1222 make_task("l2a", Status::Open, Priority::P1, vec!["leaf"]),
1223 make_task("l2b", Status::Open, Priority::P1, vec!["leaf"]),
1224 make_task("leaf", Status::Open, Priority::P1, vec![]),
1225 ]);
1226 let graph = Graph::build(&tasks);
1227 let tree = graph.dep_tree(&tasks, "root").unwrap();
1228
1229 let node_count = count_nodes(&tree);
1234 let v = tasks.len(); let e: usize = tasks.values().map(|t| t.depends_on.len()).sum(); assert!(
1237 node_count <= v + e,
1238 "node_count={node_count} exceeded V+E={} — exponential blowup detected",
1239 v + e
1240 );
1241 }
1242
1243 fn make_dense_tasks(n: usize) -> HashMap<String, Task> {
1252 let mut list = Vec::with_capacity(n + 1);
1253 list.push(make_task("bottom", Status::Done, Priority::P2, vec![]));
1255 for i in 0..n {
1256 let id = format!("t{i:04}");
1257 let priority = if i % 10 == 0 {
1258 Priority::P0
1259 } else {
1260 Priority::P3
1261 };
1262 list.push(make_task(&id, Status::Open, priority, vec![]));
1263 }
1264 let mut map: HashMap<String, Task> = list.into_iter().map(|t| (t.id.clone(), t)).collect();
1266 for i in 0..n {
1267 let id = format!("t{i:04}");
1268 let mut deps = vec!["bottom".to_string()];
1269 if i > 0 {
1270 deps.push(format!("t{:04}", i - 1));
1271 }
1272 map.get_mut(&id).unwrap().depends_on = deps;
1273 }
1274 map
1275 }
1276
1277 #[test]
1278 #[ignore]
1279 fn bench_effective_priorities_large_graph() {
1280 let n = 500;
1283 let tasks = make_dense_tasks(n);
1284 let graph = Graph::build(&tasks);
1285
1286 let start = std::time::Instant::now();
1287 let eff = graph.effective_priorities_all(&tasks);
1288 let elapsed = start.elapsed();
1289
1290 assert_eq!(eff.len(), n + 1); assert!(
1292 elapsed.as_millis() < 500,
1293 "effective_priorities_all on {n} tasks took {}ms (expected <500ms)",
1294 elapsed.as_millis()
1295 );
1296 }
1297
1298 #[test]
1299 #[ignore]
1300 fn bench_ready_large_graph() {
1301 let n = 500;
1304 let tasks = make_dense_tasks(n);
1305 let graph = Graph::build(&tasks);
1306
1307 let start = std::time::Instant::now();
1308 let ready = graph.ready(&tasks, None, None, None);
1309 let elapsed = start.elapsed();
1310
1311 assert!(
1313 ready.iter().any(|t| t.id == "t0000"),
1314 "t0000 should be in the ready list"
1315 );
1316 assert!(
1317 elapsed.as_millis() < 500,
1318 "graph.ready on {n} tasks took {}ms (expected <500ms)",
1319 elapsed.as_millis()
1320 );
1321 }
1322
1323 #[test]
1324 #[ignore]
1325 fn bench_dep_tree_diamond_deep() {
1326 let layers = 6usize;
1330 let mut map: HashMap<String, Task> = HashMap::new();
1336 let root = make_task("root", Status::Open, Priority::P1, vec![]);
1337 map.insert("root".to_string(), root);
1338
1339 let mut layer_ids: Vec<Vec<String>> = Vec::new();
1340 layer_ids.push(vec!["root".to_string()]);
1342 for l in 1..layers {
1344 let count = if l == layers - 1 {
1345 1
1346 } else {
1347 2usize.pow(l as u32)
1348 };
1349 let ids: Vec<String> = (0..count).map(|i| format!("l{l}_{i}")).collect();
1350 layer_ids.push(ids);
1351 }
1352
1353 for ids in &layer_ids {
1355 for id in ids {
1356 let t = make_task(id, Status::Open, Priority::P1, vec![]);
1357 map.insert(id.clone(), t);
1358 }
1359 }
1360 for l in 0..layers - 1 {
1362 let next = layer_ids[l + 1].clone();
1363 for id in &layer_ids[l] {
1364 map.get_mut(id).unwrap().depends_on = next.clone();
1365 }
1366 }
1367
1368 let graph = Graph::build(&map);
1369 let start = std::time::Instant::now();
1370 let tree = graph.dep_tree(&map, "root").unwrap();
1371 let elapsed = start.elapsed();
1372
1373 let node_count = count_nodes(&tree);
1374 let v = map.len();
1375 let e: usize = map.values().map(|t| t.depends_on.len()).sum();
1376 assert!(
1377 node_count <= v + e,
1378 "node_count={node_count} exceeded V+E={} — exponential blowup",
1379 v + e
1380 );
1381 assert!(
1382 elapsed.as_millis() < 500,
1383 "dep_tree on {layers}-layer diamond took {}ms (expected <500ms)",
1384 elapsed.as_millis()
1385 );
1386 }
1387}