Skip to main content

bears/
graph.rs

1use crate::task::{self, Priority, Status, Task};
2use serde::Serialize;
3use std::collections::{HashMap, HashSet, VecDeque};
4
5/// Return `true` if `task` is individually ready to work on.
6///
7/// A task is ready when:
8/// - its status is [`Status::Open`],
9/// - its type is a task (not an epic), and
10/// - every entry in `depends_on` resolves to a [`Status::Done`] task; a
11///   missing dependency is treated as **not done** and therefore blocks
12///   readiness.
13///
14/// This is the single canonical readiness predicate used by both
15/// [`Graph::ready`] and the TUI's Ready filter.
16pub 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, // missing dep blocks readiness
25            })
26}
27
28/// Dependency graph built from task `depends_on` fields.
29pub struct Graph {
30    /// task_id -> set of task IDs it depends on
31    pub edges: HashMap<String, HashSet<String>>,
32    /// task_id -> set of task IDs that depend on it (reverse edges)
33    pub reverse: HashMap<String, HashSet<String>>,
34}
35
36impl Graph {
37    /// Build a dependency graph from a set of tasks.
38    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    /// Return tasks that are ready: status is Open and all dependencies are Done.
62    ///
63    /// Effective priorities are computed once in O(V+E) rather than per-task
64    /// inside the sort comparator.
65    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        // Sort by effective priority (P0 first), then by creation date (oldest first).
82        // The priority map was computed once above — no repeated BFS in the comparator.
83        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    /// Compute the effective priority of a single task.
99    /// This is the minimum (highest urgency) of the task's own priority and
100    /// the priorities of all tasks that depend on it, transitively.
101    ///
102    /// For bulk computation prefer [`Graph::effective_priorities_all`] which
103    /// runs in O(V+E) instead of O(V+E) per call.
104    #[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    /// Compute effective priorities for ALL tasks in a single O(V+E) pass.
112    ///
113    /// `effective(x) = min(own(x), min over direct dependents y of effective(y))`
114    ///
115    /// We process nodes in reverse-topological order (dependents before their
116    /// dependencies) so that by the time we visit a node its dependents are
117    /// already resolved. Nodes that participate in a cycle are not reached by
118    /// the topological pass and keep their own priority as a safe fallback.
119    pub fn effective_priorities_all(
120        &self,
121        tasks: &HashMap<String, Task>,
122    ) -> HashMap<String, Priority> {
123        // --- Step 1: Kahn topological sort over *all* known nodes ---------------
124        // We sort forward along `edges` (A depends-on B → edge A→B).
125        // Collect every node id from both maps.
126        let all_ids: HashSet<&str> = self
127            .edges
128            .keys()
129            .chain(self.reverse.keys())
130            .map(String::as_str)
131            .collect();
132
133        // in_degree counts how many dependencies each node has (intra-graph only).
134        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        // Any id not reached by Kahn's is in a cycle — will use own priority.
165
166        // --- Step 2: propagate in reverse-topological order ---------------------
167        // Process dependents first (end of topo_order) down to dependencies (start).
168        let mut eff: HashMap<String, Priority> = HashMap::with_capacity(all_ids.len());
169
170        // Seed: every node starts with its own priority.
171        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        // Walk in reverse topological order: process dependents before dependencies.
177        for &id in topo_order.iter().rev() {
178            // Collect the best priority among all direct dependents of `id`.
179            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    /// Check if adding an edge from -> to would create a cycle.
197    /// Does BFS from `to` following edges; if we reach `from`, it's a cycle.
198    pub fn would_cycle(&self, from: &str, to: &str) -> bool {
199        if from == to {
200            return true;
201        }
202
203        // BFS from `to` through its dependencies. If we reach `from`, adding
204        // from -> to would create a cycle (since `to` already reaches `from`).
205        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(&current) {
217                for dep in deps {
218                    queue.push_back(dep.clone());
219                }
220            }
221        }
222
223        false
224    }
225
226    /// Build a dependency tree for display.
227    ///
228    /// Uses two sets to bound rendering:
229    /// - `visiting` (path-local): cycle detection — a node on the current
230    ///   recursion path is emitted as a leaf with `cycle: true`.
231    /// - `seen` (render-global): DAG deduplication — a node already fully
232    ///   expanded on *any* earlier path is emitted as a leaf with `seen: true`
233    ///   (and no children), preventing exponential blowup on diamond shapes.
234    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            // Already on the current recursion path — cycle detected.
251            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            // Already fully expanded on an earlier path — emit a reference leaf
261            // to avoid re-expanding (prevents exponential blowup on diamonds).
262            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    /// Topological sort over a subset of task IDs.
288    /// Only dependency edges between tasks in the subset are considered.
289    /// Tie-breaking: priority (P0 first), then creation date (oldest first).
290    /// Tasks caught in a dependency cycle cannot be ordered and are returned
291    /// separately in `cyclic` instead of being silently dropped.
292    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        // Compute in-degrees considering only intra-subset edges
302        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        // Seed with zero-in-degree nodes, sorted by priority then created.
317        // Use VecDeque for O(1) front-pop (Vec::remove(0) is O(n)).
318        // NOTE: newly-ready nodes are sorted among themselves and appended to the
319        // back of the queue rather than merged into the existing entries, so the
320        // overall ordering is only approximately priority-sorted when independent
321        // batches interleave. This is intentional: the fully-correct merge would
322        // require a priority-queue rebuild on every step and is not worth the
323        // complexity for the plan display use-case.
324        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            // Decrement in-degree for tasks that depend on current
348            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                // Sort newly ready by priority then created before appending
361                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        // Anything left over never reached in-degree 0: a dependency cycle.
377        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    /// Get adjacency list for JSON output (full, unfiltered).
392    #[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    /// Get a bounded adjacency list suitable for MCP/JSON output.
404    ///
405    /// Excludes:
406    /// - Done/cancelled tasks by default (unless `include_done` is true)
407    /// - Isolated nodes that have no dependencies and no dependents within
408    ///   the included set
409    ///
410    /// Optional filters:
411    /// - `epic`: include only children of the given epic ID (plus the epic itself)
412    /// - `limit`: cap the number of nodes in the output
413    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        // Step 1: build the eligible node set
423        let eligible: HashSet<&str> = tasks
424            .values()
425            .filter(|t| {
426                // Status filter
427                if !include_done && (t.status == Status::Done || t.status == Status::Cancelled) {
428                    return false;
429                }
430                // Epic filter: if specified, include only direct children + the epic itself
431                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        // Step 2: compute intra-eligible edges
440        // A node is connected if it has at least one dep or dependent within eligible set
441        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        // Step 3: build adjacency map for connected nodes only
454        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        // Sort deterministically by id for stable output
470        result.sort_by_key(|(id, _)| *id);
471
472        // Apply limit
473        if let Some(limit) = limit {
474            result.truncate(limit);
475        }
476
477        result.into_iter().collect()
478    }
479}
480
481/// Result of a subset topological sort.
482#[derive(Default)]
483pub struct SubsetTopo<'a> {
484    /// Tasks in execution order.
485    pub sorted: Vec<&'a Task>,
486    /// Tasks that could not be ordered because they are in a dependency cycle.
487    pub cyclic: Vec<&'a Task>,
488}
489
490pub struct DepNode<'a> {
491    pub task: &'a Task,
492    pub children: Vec<DepNode<'a>>,
493    /// True when this node was already seen on the **current path** (cycle).
494    pub cycle: bool,
495    /// True when this node was already fully expanded on an **earlier path**
496    /// (DAG diamond deduplication). Distinct from `cycle`.
497    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        // P0 should come first
567        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        // Only b is ready (a is done, c depends on b which isn't done)
582        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        // Task "b" depends on "nonexistent" which is not in the task map
600        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        // Only task "a" should be ready, not epic "e"
646        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        // Filter to epic1 — only task "a"
661        let ready = graph.ready(&tasks, None, None, Some("epic1"));
662        assert_eq!(ready.len(), 1);
663        assert_eq!(ready[0].id, "a");
664
665        // Filter to epic2 — only task "b"
666        let ready = graph.ready(&tasks, None, None, Some("epic2"));
667        assert_eq!(ready.len(), 1);
668        assert_eq!(ready[0].id, "b");
669
670        // No filter — all three
671        let ready = graph.ready(&tasks, None, None, None);
672        assert_eq!(ready.len(), 3);
673    }
674
675    // --- is_task_ready predicate tests -------------------------------------------
676
677    #[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        // A dep that does not exist in the task map blocks readiness.
704        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    /// Verify that `Graph::ready` and `is_task_ready` agree on every task:
733    /// every task in the ready list satisfies the predicate and vice-versa.
734    #[test]
735    fn test_ready_and_is_task_ready_agree() {
736        let tasks = make_tasks(vec![
737            // t1: open, no deps → ready
738            make_task("t1", Status::Open, Priority::P1, vec![]),
739            // t2: open, dep on t1 (open, not done) → NOT ready
740            make_task("t2", Status::Open, Priority::P1, vec!["t1"]),
741            // t3: open, dep on t4 (done) → ready
742            make_task("t3", Status::Open, Priority::P1, vec!["t4"]),
743            // t4: done → not in ready list (not Open)
744            make_task("t4", Status::Done, Priority::P1, vec![]),
745            // t5: open, dep on "ghost" (missing) → NOT ready
746            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        // a depends on b. Would adding b -> a create a cycle? Yes.
776        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        // a -> b -> c. Would adding c -> a create a cycle? Yes.
787        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        // Graph:
842        //   a (open) -> b (open)   ← connected pair
843        //   c (done)               ← isolated done node
844        //   d (open, isolated)     ← isolated open node
845        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        // Default: exclude done, exclude isolated
854        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        // include_done=true: c is now eligible but still isolated → excluded
864        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        // a -> b -> a  (direct cycle)
880        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        // b's child "a" should be a cycle marker
889        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        // a -> b -> c -> a  (transitive cycle)
902        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        // a -> a  (self-referencing)
923        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        // Diamond: a -> b, a -> c, b -> d, c -> d (no cycle).
937        // With DAG deduplication, d is fully expanded under the FIRST child that
938        // visits it and emitted as a `seen` reference leaf under the second.
939        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        // Both b and c appear under a (neither is cycle or seen at that level)
950        assert_eq!(tree.children.len(), 2);
951        for child in &tree.children {
952            assert!(!child.cycle);
953            // Each should have exactly one child for d
954            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            // d is expanded fully the first time; second occurrence is `seen`
959        }
960        // Exactly one of the two d appearances is seen (the second visit)
961        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        // Task with no dependents: effective == own
982        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        // "a" is depended on by "b" (P1). a's own priority is P3.
990        // b depends_on a → a's effective should be P1
991        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        // b has no dependents, so effective == own
998        assert_eq!(graph.effective_priority("b", &tasks), Priority::P1);
999    }
1000
1001    #[test]
1002    fn test_effective_priority_chain() {
1003        // c (P0) -> b (P2) -> a (P3)
1004        // a's effective should be P0 (transitive through b from c)
1005        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        // d (P0) -> b (P2), d (P0) -> c (P3), b -> a (P3), c -> a (P3)
1019        // a's effective should be P0 (via both paths)
1020        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        // Task's own priority is already highest — should stay same
1035        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        // c depends on b, b depends on a → expect [a, b, c]
1046        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        // d has no deps, b depends on d, c depends on d, a depends on b and c
1061        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        // d must come first, a must come last
1072        assert_eq!(ids[0], "d");
1073        assert_eq!(ids[ids.len() - 1], "a");
1074    }
1075
1076    #[test]
1077    fn test_topo_sort_independent() {
1078        // No deps among subset, sorted by priority then created
1079        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        // P0 first, then P1, then P2
1089        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        // a <-> b cycle, c independent: c sorts, a and b are reported cyclic.
1114        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        // b depends on "ext" which is not in the subset
1134        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    // --- Coupled-graph regression guards ------------------------------------------
1146    //
1147    // Fast structural tests (always run) verify that algorithmic fixes hold for
1148    // small representative graphs without any timing dependency.
1149    //
1150    // Heavy timing tests are marked `#[ignore]` and must be run explicitly with
1151    //   cargo test -- --ignored
1152    // They use std::time::Instant with generous bounds; any reasonable hardware
1153    // should satisfy them.
1154
1155    fn count_nodes(node: &DepNode<'_>) -> usize {
1156        1 + node.children.iter().map(count_nodes).sum::<usize>()
1157    }
1158
1159    /// Fast structural check: effective priorities on a long chain are correct.
1160    /// If effective_priorities_all were O(V²) or O(V*E), the values would still
1161    /// be correct but the large graph below would timeout — this test documents
1162    /// expected values on a chain without relying on timing.
1163    #[test]
1164    fn test_effective_priority_long_chain_correct() {
1165        // Build chain: t0 <- t1 <- t2 <- ... <- t19
1166        // t19 has priority P0; all others have P3.
1167        // effective(t0) should be P0 (propagated from t19 through the chain).
1168        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        // Wire chain: t_{i+1} depends on t_i
1179        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        // effective(t0) must be P0 (t19 depends transitively on t0's output)
1186        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        // t19 has own P0
1198        assert_eq!(eff["t19"], Priority::P0);
1199    }
1200
1201    #[test]
1202    fn test_dep_tree_deep_diamond_linear_node_count() {
1203        // Build a "double-fan" diamond: root depends on L layers, each layer
1204        // node depends on all nodes in the next layer, converging to a single
1205        // shared leaf at the bottom.
1206        //
1207        //   root
1208        //    |
1209        //   L1a, L1b          (both depend on L2a, L2b)
1210        //       |
1211        //   L2a, L2b          (both depend on leaf)
1212        //       |
1213        //    leaf
1214        //
1215        // Without deduplication the tree would expand leaf 4 times (2^2).
1216        // With the `seen` fix it appears at most once as a full node plus
1217        // `seen`-ref placeholders — total node count stays bounded by O(V+E).
1218        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        // 6 distinct nodes → maximum rendered nodes = 6 (V) + 4 (E where
1230        // second-visit refs are emitted) = at most V+E = 10. In practice we
1231        // get exactly V + (number of seen-ref appearances) which is at most
1232        // V+E, well below the exponential 2^layers.
1233        let node_count = count_nodes(&tree);
1234        let v = tasks.len(); // 6
1235        let e: usize = tasks.values().map(|t| t.depends_on.len()).sum(); // 8
1236        assert!(
1237            node_count <= v + e,
1238            "node_count={node_count} exceeded V+E={} — exponential blowup detected",
1239            v + e
1240        );
1241    }
1242
1243    // --- #[ignore]'d timing benchmarks (run with: cargo test -- --ignored) --------
1244    //
1245    // These build a densely coupled graph of N tasks and assert that the key
1246    // operations complete well within a generous wall-clock bound. They are
1247    // `#[ignore]` to avoid slowing down the default `cargo test` run.
1248
1249    /// Build N tasks wired as a full "staircase": task i depends on task i-1,
1250    /// plus every 5th task depends on a shared "bottom" task.
1251    fn make_dense_tasks(n: usize) -> HashMap<String, Task> {
1252        let mut list = Vec::with_capacity(n + 1);
1253        // Shared bottom-of-chain task
1254        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        // Wire dependencies after creation (make_task takes &str slice)
1265        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        // 500-node staircase — each node depends on 1-2 predecessors.
1281        // effective_priorities_all should finish in well under 1 second.
1282        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); // n tasks + bottom
1291        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        // 500-node staircase. Only t0000 is truly ready (all deps done = bottom only).
1302        // Graph::ready should finish in well under 1 second.
1303        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        // t0000 depends only on "bottom" (done), so it must be ready
1312        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        // Build a deep 6-layer binary diamond: layer 0 → layer 1 (×2) → ... → layer 5 (1 node).
1327        // Without the `seen` fix this would be exponential (2^5 = 32 leaf expansions).
1328        // With the fix, node count should stay linear in V+E.
1329        let layers = 6usize;
1330        // layer 0: 1 node (root)
1331        // layer k: 2^k nodes, each depending on all nodes in layer k+1
1332        // layer 5: 1 node (shared leaf)
1333        // We'll use owned ids; can't use &str in make_task with dynamic strings directly.
1334        // Build manually.
1335        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 0 = root
1341        layer_ids.push(vec!["root".to_string()]);
1342        // layers 1..=layers-1
1343        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        // Create all tasks (no deps yet)
1354        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        // Wire: each node in layer l depends on all nodes in layer l+1
1361        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}