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            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        // P0 should come first
568        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        // Only b is ready (a is done, c depends on b which isn't done)
583        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        // Task "b" depends on "nonexistent" which is not in the task map
601        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        // Only task "a" should be ready, not epic "e"
647        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        // Filter to epic1 — only task "a"
662        let ready = graph.ready(&tasks, None, None, Some("epic1"));
663        assert_eq!(ready.len(), 1);
664        assert_eq!(ready[0].id, "a");
665
666        // Filter to epic2 — only task "b"
667        let ready = graph.ready(&tasks, None, None, Some("epic2"));
668        assert_eq!(ready.len(), 1);
669        assert_eq!(ready[0].id, "b");
670
671        // No filter — all three
672        let ready = graph.ready(&tasks, None, None, None);
673        assert_eq!(ready.len(), 3);
674    }
675
676    // --- is_task_ready predicate tests -------------------------------------------
677
678    #[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        // A dep that does not exist in the task map blocks readiness.
705        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    /// Verify that `Graph::ready` and `is_task_ready` agree on every task:
734    /// every task in the ready list satisfies the predicate and vice-versa.
735    #[test]
736    fn test_ready_and_is_task_ready_agree() {
737        let tasks = make_tasks(vec![
738            // t1: open, no deps → ready
739            make_task("t1", Status::Open, Priority::P1, vec![]),
740            // t2: open, dep on t1 (open, not done) → NOT ready
741            make_task("t2", Status::Open, Priority::P1, vec!["t1"]),
742            // t3: open, dep on t4 (done) → ready
743            make_task("t3", Status::Open, Priority::P1, vec!["t4"]),
744            // t4: done → not in ready list (not Open)
745            make_task("t4", Status::Done, Priority::P1, vec![]),
746            // t5: open, dep on "ghost" (missing) → NOT ready
747            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        // a depends on b. Would adding b -> a create a cycle? Yes.
777        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        // a -> b -> c. Would adding c -> a create a cycle? Yes.
788        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        // Graph:
843        //   a (open) -> b (open)   ← connected pair
844        //   c (done)               ← isolated done node
845        //   d (open, isolated)     ← isolated open node
846        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        // Default: exclude done, exclude isolated
855        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        // include_done=true: c is now eligible but still isolated → excluded
865        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        // a -> b -> a  (direct cycle)
881        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        // b's child "a" should be a cycle marker
890        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        // a -> b -> c -> a  (transitive cycle)
903        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        // a -> a  (self-referencing)
924        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        // Diamond: a -> b, a -> c, b -> d, c -> d (no cycle).
938        // With DAG deduplication, d is fully expanded under the FIRST child that
939        // visits it and emitted as a `seen` reference leaf under the second.
940        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        // Both b and c appear under a (neither is cycle or seen at that level)
951        assert_eq!(tree.children.len(), 2);
952        for child in &tree.children {
953            assert!(!child.cycle);
954            // Each should have exactly one child for d
955            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            // d is expanded fully the first time; second occurrence is `seen`
960        }
961        // Exactly one of the two d appearances is seen (the second visit)
962        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        // Task with no dependents: effective == own
983        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        // "a" is depended on by "b" (P1). a's own priority is P3.
991        // b depends_on a → a's effective should be P1
992        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        // b has no dependents, so effective == own
999        assert_eq!(graph.effective_priority("b", &tasks), Priority::P1);
1000    }
1001
1002    #[test]
1003    fn test_effective_priority_chain() {
1004        // c (P0) -> b (P2) -> a (P3)
1005        // a's effective should be P0 (transitive through b from c)
1006        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        // d (P0) -> b (P2), d (P0) -> c (P3), b -> a (P3), c -> a (P3)
1020        // a's effective should be P0 (via both paths)
1021        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        // Task's own priority is already highest — should stay same
1036        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        // c depends on b, b depends on a → expect [a, b, c]
1047        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        // d has no deps, b depends on d, c depends on d, a depends on b and c
1062        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        // d must come first, a must come last
1073        assert_eq!(ids[0], "d");
1074        assert_eq!(ids[ids.len() - 1], "a");
1075    }
1076
1077    #[test]
1078    fn test_topo_sort_independent() {
1079        // No deps among subset, sorted by priority then created
1080        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        // P0 first, then P1, then P2
1090        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        // a <-> b cycle, c independent: c sorts, a and b are reported cyclic.
1115        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        // b depends on "ext" which is not in the subset
1135        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    // --- Coupled-graph regression guards ------------------------------------------
1147    //
1148    // Fast structural tests (always run) verify that algorithmic fixes hold for
1149    // small representative graphs without any timing dependency.
1150    //
1151    // Heavy timing tests are marked `#[ignore]` and must be run explicitly with
1152    //   cargo test -- --ignored
1153    // They use std::time::Instant with generous bounds; any reasonable hardware
1154    // should satisfy them.
1155
1156    fn count_nodes(node: &DepNode<'_>) -> usize {
1157        1 + node.children.iter().map(count_nodes).sum::<usize>()
1158    }
1159
1160    /// Fast structural check: effective priorities on a long chain are correct.
1161    /// If effective_priorities_all were O(V²) or O(V*E), the values would still
1162    /// be correct but the large graph below would timeout — this test documents
1163    /// expected values on a chain without relying on timing.
1164    #[test]
1165    fn test_effective_priority_long_chain_correct() {
1166        // Build chain: t0 <- t1 <- t2 <- ... <- t19
1167        // t19 has priority P0; all others have P3.
1168        // effective(t0) should be P0 (propagated from t19 through the chain).
1169        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        // Wire chain: t_{i+1} depends on t_i
1180        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        // effective(t0) must be P0 (t19 depends transitively on t0's output)
1187        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        // t19 has own P0
1199        assert_eq!(eff["t19"], Priority::P0);
1200    }
1201
1202    #[test]
1203    fn test_dep_tree_deep_diamond_linear_node_count() {
1204        // Build a "double-fan" diamond: root depends on L layers, each layer
1205        // node depends on all nodes in the next layer, converging to a single
1206        // shared leaf at the bottom.
1207        //
1208        //   root
1209        //    |
1210        //   L1a, L1b          (both depend on L2a, L2b)
1211        //       |
1212        //   L2a, L2b          (both depend on leaf)
1213        //       |
1214        //    leaf
1215        //
1216        // Without deduplication the tree would expand leaf 4 times (2^2).
1217        // With the `seen` fix it appears at most once as a full node plus
1218        // `seen`-ref placeholders — total node count stays bounded by O(V+E).
1219        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        // 6 distinct nodes → maximum rendered nodes = 6 (V) + 4 (E where
1231        // second-visit refs are emitted) = at most V+E = 10. In practice we
1232        // get exactly V + (number of seen-ref appearances) which is at most
1233        // V+E, well below the exponential 2^layers.
1234        let node_count = count_nodes(&tree);
1235        let v = tasks.len(); // 6
1236        let e: usize = tasks.values().map(|t| t.depends_on.len()).sum(); // 8
1237        assert!(
1238            node_count <= v + e,
1239            "node_count={node_count} exceeded V+E={} — exponential blowup detected",
1240            v + e
1241        );
1242    }
1243
1244    // --- #[ignore]'d timing benchmarks (run with: cargo test -- --ignored) --------
1245    //
1246    // These build a densely coupled graph of N tasks and assert that the key
1247    // operations complete well within a generous wall-clock bound. They are
1248    // `#[ignore]` to avoid slowing down the default `cargo test` run.
1249
1250    /// Build N tasks wired as a full "staircase": task i depends on task i-1,
1251    /// plus every 5th task depends on a shared "bottom" task.
1252    fn make_dense_tasks(n: usize) -> HashMap<String, Task> {
1253        let mut list = Vec::with_capacity(n + 1);
1254        // Shared bottom-of-chain task
1255        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        // Wire dependencies after creation (make_task takes &str slice)
1266        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        // 500-node staircase — each node depends on 1-2 predecessors.
1282        // effective_priorities_all should finish in well under 1 second.
1283        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); // n tasks + bottom
1292        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        // 500-node staircase. Only t0000 is truly ready (all deps done = bottom only).
1303        // Graph::ready should finish in well under 1 second.
1304        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        // t0000 depends only on "bottom" (done), so it must be ready
1313        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        // Build a deep 6-layer binary diamond: layer 0 → layer 1 (×2) → ... → layer 5 (1 node).
1328        // Without the `seen` fix this would be exponential (2^5 = 32 leaf expansions).
1329        // With the fix, node count should stay linear in V+E.
1330        let layers = 6usize;
1331        // layer 0: 1 node (root)
1332        // layer k: 2^k nodes, each depending on all nodes in layer k+1
1333        // layer 5: 1 node (shared leaf)
1334        // We'll use owned ids; can't use &str in make_task with dynamic strings directly.
1335        // Build manually.
1336        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 0 = root
1342        layer_ids.push(vec!["root".to_string()]);
1343        // layers 1..=layers-1
1344        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        // Create all tasks (no deps yet)
1355        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        // Wire: each node in layer l depends on all nodes in layer l+1
1362        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}