Skip to main content

ftui_layout/
dep_graph.rs

1//! Dependency graph for incremental layout invalidation (bd-3p4y1.1).
2//!
3//! # Design
4//!
5//! The dependency graph tracks which layout computations depend on which
6//! inputs. When an input changes (constraint, content, style, children),
7//! only the affected subtree is marked dirty and re-evaluated.
8//!
9//! ## Data Structure: Per-Node Adjacency Lists
10//!
11//! Each node stores fixed-size metadata (36 bytes) inline. Edges are
12//! stored in per-node `Vec<NodeId>` lists for both forward (deps) and
13//! reverse (dependents) directions, ensuring correct adjacency even
14//! when edges are added in arbitrary order.
15//!
16//! ### Complexity
17//!
18//! | Operation            | Time         | Space           |
19//! |----------------------|--------------|-----------------|
20//! | Create node          | O(1) amort.  | +36 bytes       |
21//! | Mark dirty           | O(1)         | —               |
22//! | Propagate dirty      | O(k)         | O(k) queue      |
23//! | Add dependency edge  | O(V+E) cycle | +4 bytes/edge   |
24//! | Cycle detection      | O(V + E)     | O(V) coloring   |
25//! | Query dirty set      | O(n) scan    | —               |
26//!
27//! Where k = dirty nodes + their transitive dependents, n = total nodes.
28//!
29//! ### Memory: 40 bytes per node (struct only)
30//!
31//! ```text
32//! DepNode {
33//!     generation: u32,       //  4 bytes — dirty-check generation
34//!     dirty_gen: u32,        //  4 bytes — generation when last dirtied
35//!     constraint_hash: u64,  //  8 bytes — detect constraint changes
36//!     content_hash: u64,     //  8 bytes — detect content changes
37//!     style_hash: u64,       //  8 bytes — detect style changes
38//!     parent: u32,           //  4 bytes — parent NodeId (u32::MAX = none)
39//! }                          // = 40 bytes (36 raw + 4 alignment padding)
40//! ```
41//!
42//! # Dirty Propagation
43//!
44//! When a node is marked dirty, BFS traverses reverse edges (dependents)
45//! and marks each reachable node dirty. The dirty set is the transitive
46//! closure of the initially dirty nodes under the "depends-on" relation.
47//!
48//! # Cycle Detection
49//!
50//! Layout cycles are bugs. The graph detects cycles on edge insertion
51//! using DFS reachability: before adding A → B, check that B cannot
52//! already reach A via existing edges. This is O(V + E) worst case
53//! but typically fast due to shallow widget trees.
54//!
55//! # Deterministic Traversal
56//!
57//! Dirty nodes are visited in DFS pre-order (matching full layout
58//! traversal), ensuring bit-identical output regardless of whether
59//! the computation is incremental or full.
60
61use std::collections::VecDeque;
62use std::fmt;
63
64// ============================================================================
65// NodeId
66// ============================================================================
67
68/// Lightweight handle into the dependency graph.
69///
70/// Uses `u32` for compactness (supports up to ~4 billion nodes).
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub struct NodeId(u32);
73
74impl NodeId {
75    /// Sentinel value meaning "no parent".
76    const NONE: u32 = u32::MAX;
77
78    /// Create a NodeId from a raw u32 index.
79    #[must_use]
80    pub fn from_raw(index: u32) -> Self {
81        Self(index)
82    }
83
84    /// Get the raw u32 index.
85    #[must_use]
86    pub fn raw(self) -> u32 {
87        self.0
88    }
89}
90
91impl fmt::Display for NodeId {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "N{}", self.0)
94    }
95}
96
97// ============================================================================
98// InputHash — what changed
99// ============================================================================
100
101/// Identifies which input domain changed on a node.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum InputKind {
104    /// Size constraints (min/max/preferred width/height).
105    Constraint,
106    /// Widget content (text, children list, embedded content).
107    Content,
108    /// Style properties (padding, margin, border, flex properties).
109    Style,
110}
111
112// ============================================================================
113// DepNode
114// ============================================================================
115
116/// Per-node metadata in the dependency graph. 40 bytes (36 raw + alignment).
117///
118/// Edge adjacency is stored separately in `Vec<Vec<NodeId>>` to ensure
119/// correctness when edges are added in arbitrary order.
120#[derive(Clone)]
121struct DepNode {
122    /// Global generation counter at creation time.
123    generation: u32,
124    /// Generation when this node was last marked dirty.
125    /// Dirty if `dirty_gen >= generation` (after last clean).
126    dirty_gen: u32,
127    /// Hash of constraint inputs.
128    constraint_hash: u64,
129    /// Hash of content inputs.
130    content_hash: u64,
131    /// Hash of style inputs.
132    style_hash: u64,
133    /// Parent node (u32::MAX = root/no parent).
134    parent: u32,
135}
136
137impl DepNode {
138    fn new(generation: u32) -> Self {
139        Self {
140            generation,
141            dirty_gen: 0,
142            constraint_hash: 0,
143            content_hash: 0,
144            style_hash: 0,
145            parent: NodeId::NONE,
146        }
147    }
148
149    fn is_dirty(&self) -> bool {
150        self.dirty_gen >= self.generation
151    }
152}
153
154// ============================================================================
155// CycleError
156// ============================================================================
157
158/// Error returned when adding an edge would create a cycle.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct CycleError {
161    pub from: NodeId,
162    pub to: NodeId,
163}
164
165impl fmt::Display for CycleError {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(
168            f,
169            "layout cycle detected: {} → {} would create a cycle",
170            self.from, self.to
171        )
172    }
173}
174
175impl std::error::Error for CycleError {}
176
177// ============================================================================
178// DepGraph
179// ============================================================================
180
181/// Dependency graph for incremental layout invalidation.
182///
183/// Tracks layout nodes and their dependencies. When a node's input
184/// changes, the graph propagates dirtiness to all transitive dependents.
185///
186/// # Examples
187///
188/// ```
189/// use ftui_layout::dep_graph::{DepGraph, InputKind};
190///
191/// let mut graph = DepGraph::new();
192///
193/// // Create a simple parent → child dependency.
194/// let parent = graph.add_node();
195/// let child = graph.add_node();
196/// graph.add_edge(child, parent).unwrap();  // child depends on parent
197///
198/// // Changing the parent dirties the child.
199/// graph.mark_changed(parent, InputKind::Constraint, 42);
200/// let dirty = graph.propagate();
201/// assert!(dirty.contains(&parent));
202/// assert!(dirty.contains(&child));
203/// ```
204pub struct DepGraph {
205    nodes: Vec<DepNode>,
206    /// Forward adjacency: `fwd_adj[i]` = nodes that node `i` depends on.
207    fwd_adj: Vec<Vec<NodeId>>,
208    /// Reverse adjacency: `rev_adj[i]` = nodes that depend on node `i`.
209    rev_adj: Vec<Vec<NodeId>>,
210    /// Current generation counter for dirty tracking.
211    current_gen: u32,
212    /// Pending dirty nodes (not yet propagated).
213    pending_dirty: Vec<NodeId>,
214    /// Free list for recycled node slots.
215    free_list: Vec<u32>,
216}
217
218impl DepGraph {
219    /// Create an empty dependency graph.
220    #[must_use]
221    pub fn new() -> Self {
222        Self {
223            nodes: Vec::new(),
224            fwd_adj: Vec::new(),
225            rev_adj: Vec::new(),
226            current_gen: 1,
227            pending_dirty: Vec::new(),
228            free_list: Vec::new(),
229        }
230    }
231
232    /// Create a graph with pre-allocated capacity.
233    #[must_use]
234    pub fn with_capacity(node_cap: usize, _edge_cap: usize) -> Self {
235        Self {
236            nodes: Vec::with_capacity(node_cap),
237            fwd_adj: Vec::with_capacity(node_cap),
238            rev_adj: Vec::with_capacity(node_cap),
239            current_gen: 1,
240            pending_dirty: Vec::new(),
241            free_list: Vec::new(),
242        }
243    }
244
245    /// Add a new node to the graph. Returns its stable identifier.
246    pub fn add_node(&mut self) -> NodeId {
247        if let Some(slot) = self.free_list.pop() {
248            self.nodes[slot as usize] = DepNode::new(self.current_gen);
249            self.fwd_adj[slot as usize].clear();
250            self.rev_adj[slot as usize].clear();
251            NodeId(slot)
252        } else {
253            let id = self.nodes.len() as u32;
254            self.nodes.push(DepNode::new(self.current_gen));
255            self.fwd_adj.push(Vec::new());
256            self.rev_adj.push(Vec::new());
257            NodeId(id)
258        }
259    }
260
261    /// Remove a node, recycling its slot. Edges and parent pointers are eagerly cleaned.
262    pub fn remove_node(&mut self, id: NodeId) {
263        let idx = id.0 as usize;
264        if idx < self.nodes.len() && self.nodes[idx].generation != 0 {
265            // Clear parent pointers for children that had this node as parent
266            for &rev in &self.rev_adj[idx] {
267                let child_idx = rev.0 as usize;
268                if child_idx < self.nodes.len() && self.nodes[child_idx].parent == id.0 {
269                    self.nodes[child_idx].parent = NodeId::NONE;
270                }
271            }
272
273            // Remove `id` from `rev_adj` of all nodes that `id` depends on (fwd_adj)
274            let fwds = std::mem::take(&mut self.fwd_adj[idx]);
275            for fwd in fwds {
276                if let Some(revs) = self.rev_adj.get_mut(fwd.0 as usize) {
277                    revs.retain(|&x| x != id);
278                }
279            }
280
281            // Remove `id` from `fwd_adj` of all nodes that depend on `id` (rev_adj)
282            let revs = std::mem::take(&mut self.rev_adj[idx]);
283            for rev in revs {
284                if let Some(fwds) = self.fwd_adj.get_mut(rev.0 as usize) {
285                    fwds.retain(|&x| x != id);
286                }
287            }
288
289            self.nodes[idx].generation = 0;
290            self.nodes[idx].dirty_gen = 0;
291            self.free_list.push(id.0);
292        }
293    }
294
295    /// Total number of live nodes.
296    #[must_use]
297    pub fn node_count(&self) -> usize {
298        self.nodes.len() - self.free_list.len()
299    }
300
301    /// Total number of edges (forward).
302    #[must_use]
303    pub fn edge_count(&self) -> usize {
304        self.fwd_adj.iter().map(|v| v.len()).sum()
305    }
306
307    /// Set the parent of a node (structural tree relationship).
308    pub fn set_parent(&mut self, child: NodeId, parent: NodeId) {
309        if (child.0 as usize) < self.nodes.len() {
310            self.nodes[child.0 as usize].parent = parent.0;
311        }
312    }
313
314    /// Get the parent of a node, if any.
315    #[must_use]
316    pub fn parent(&self, id: NodeId) -> Option<NodeId> {
317        let node = self.nodes.get(id.0 as usize)?;
318        if node.parent == NodeId::NONE {
319            None
320        } else {
321            Some(NodeId(node.parent))
322        }
323    }
324
325    /// Add a dependency edge: `from` depends on `to`.
326    ///
327    /// Returns `Err(CycleError)` if this would create a cycle.
328    pub fn add_edge(&mut self, from: NodeId, to: NodeId) -> Result<(), CycleError> {
329        // Self-loops are cycles.
330        if from == to {
331            return Err(CycleError { from, to });
332        }
333
334        // Check: can `to` already reach `from` via existing forward edges?
335        // If so, adding `from → to` would create a cycle.
336        if self.can_reach(to, from) {
337            return Err(CycleError { from, to });
338        }
339
340        // Add forward edge: from depends on to.
341        self.fwd_adj[from.0 as usize].push(to);
342        // Add reverse edge: to is depended on by from.
343        self.rev_adj[to.0 as usize].push(from);
344
345        Ok(())
346    }
347
348    /// Check if `from` can reach `to` via forward edges (DFS).
349    fn can_reach(&self, from: NodeId, to: NodeId) -> bool {
350        let mut visited = vec![false; self.nodes.len()];
351        let mut stack = vec![from];
352
353        while let Some(current) = stack.pop() {
354            if current == to {
355                return true;
356            }
357            let idx = current.0 as usize;
358            if idx >= self.nodes.len() || visited[idx] {
359                continue;
360            }
361            visited[idx] = true;
362
363            if self.nodes[idx].generation == 0 {
364                continue; // Dead node.
365            }
366            for &dep in &self.fwd_adj[idx] {
367                if !visited[dep.0 as usize] {
368                    stack.push(dep);
369                }
370            }
371        }
372        false
373    }
374
375    /// Mark a node's input as changed. The node and its transitive
376    /// dependents will be dirtied on the next `propagate()` call.
377    ///
378    /// The `new_hash` is compared against the stored hash for the given
379    /// `kind`. If unchanged, the node is not dirtied (deduplication).
380    pub fn mark_changed(&mut self, id: NodeId, kind: InputKind, new_hash: u64) {
381        let idx = id.0 as usize;
382        if idx >= self.nodes.len() {
383            return;
384        }
385        let node = &mut self.nodes[idx];
386        if node.generation == 0 {
387            return; // Dead node.
388        }
389
390        let old_hash = match kind {
391            InputKind::Constraint => &mut node.constraint_hash,
392            InputKind::Content => &mut node.content_hash,
393            InputKind::Style => &mut node.style_hash,
394        };
395
396        if *old_hash == new_hash {
397            return; // No actual change.
398        }
399        *old_hash = new_hash;
400
401        // Mark dirty.
402        node.dirty_gen = self.current_gen;
403        self.pending_dirty.push(id);
404    }
405
406    /// Force-mark a node as dirty without hash comparison.
407    pub fn mark_dirty(&mut self, id: NodeId) {
408        let idx = id.0 as usize;
409        if idx >= self.nodes.len() {
410            return;
411        }
412        let node = &mut self.nodes[idx];
413        if node.generation == 0 {
414            return;
415        }
416        node.dirty_gen = self.current_gen;
417        self.pending_dirty.push(id);
418    }
419
420    /// Propagate dirtiness from pending dirty nodes to all transitive
421    /// dependents via BFS on reverse edges.
422    ///
423    /// Returns the complete dirty set in **DFS pre-order** (matching
424    /// full layout traversal order) for deterministic recomputation.
425    pub fn propagate(&mut self) -> Vec<NodeId> {
426        if self.pending_dirty.is_empty() {
427            return Vec::new();
428        }
429
430        // BFS to find all transitive dependents.
431        let mut queue = VecDeque::new();
432        let mut visited = vec![false; self.nodes.len()];
433
434        for &id in &self.pending_dirty {
435            let idx = id.0 as usize;
436            if idx < self.nodes.len() && !visited[idx] {
437                visited[idx] = true;
438                queue.push_back(id);
439            }
440        }
441        self.pending_dirty.clear();
442
443        while let Some(current) = queue.pop_front() {
444            let idx = current.0 as usize;
445            if self.nodes[idx].generation == 0 {
446                continue;
447            }
448
449            // Mark dirty.
450            self.nodes[idx].dirty_gen = self.current_gen;
451
452            // Enqueue all reverse-edge dependents.
453            for i in 0..self.rev_adj[idx].len() {
454                let dependent = self.rev_adj[idx][i];
455                let dep_idx = dependent.0 as usize;
456                if dep_idx < self.nodes.len() && !visited[dep_idx] {
457                    visited[dep_idx] = true;
458                    queue.push_back(dependent);
459                }
460            }
461        }
462
463        // Collect dirty set in DFS pre-order for deterministic traversal.
464        self.collect_dirty_dfs_preorder()
465    }
466
467    /// Collect all dirty nodes in DFS topological order from roots.
468    fn collect_dirty_dfs_preorder(&self) -> Vec<NodeId> {
469        // Find roots: dirty nodes with no dirty dependencies.
470        let mut roots = Vec::new();
471        for (i, node) in self.nodes.iter().enumerate() {
472            if node.generation == 0 || !node.is_dirty() {
473                continue;
474            }
475
476            let has_dirty_dependency = self.fwd_adj[i].iter().any(|&dep_id| {
477                let dep_idx = dep_id.0 as usize;
478                dep_idx < self.nodes.len()
479                    && self.nodes[dep_idx].generation != 0
480                    && self.nodes[dep_idx].is_dirty()
481            });
482
483            if !has_dirty_dependency {
484                roots.push(NodeId(i as u32));
485            }
486        }
487        roots.sort(); // Deterministic ordering.
488
489        // DFS post-order from each root to guarantee a valid topological sort
490        // even in the presence of cross-edges (DAGs). We process roots and
491        // children in reverse so that reversing the final result restores the
492        // expected top-to-bottom, left-to-right DOM traversal order.
493        let mut result = Vec::new();
494        let mut visited = vec![false; self.nodes.len()];
495
496        for root in roots.into_iter().rev() {
497            self.dfs_postorder(root, &mut result, &mut visited);
498        }
499
500        result.reverse();
501        result
502    }
503
504    /// DFS post-order traversal of dirty nodes via reverse edges (children).
505    fn dfs_postorder(&self, id: NodeId, result: &mut Vec<NodeId>, visited: &mut [bool]) {
506        let idx = id.0 as usize;
507        if idx >= self.nodes.len() || visited[idx] {
508            return;
509        }
510        let node = &self.nodes[idx];
511        if node.generation == 0 || !node.is_dirty() {
512            return;
513        }
514        visited[idx] = true;
515
516        // Visit dependents (children in the dirty tree).
517        let mut children: Vec<NodeId> = self.rev_adj[idx]
518            .iter()
519            .filter(|d| {
520                let di = d.0 as usize;
521                di < self.nodes.len()
522                    && !visited[di]
523                    && self.nodes[di].generation != 0
524                    && self.nodes[di].is_dirty()
525            })
526            .copied()
527            .collect();
528
529        children.sort(); // Deterministic.
530
531        // Iterate children in reverse for the post-order topological sort
532        for child in children.into_iter().rev() {
533            self.dfs_postorder(child, result, visited);
534        }
535
536        result.push(id);
537    }
538
539    /// Check if a node is currently dirty.
540    #[must_use]
541    pub fn is_dirty(&self, id: NodeId) -> bool {
542        self.nodes
543            .get(id.0 as usize)
544            .is_some_and(|n| n.generation != 0 && n.is_dirty())
545    }
546
547    /// Clean a single node (mark as not dirty).
548    pub fn clean(&mut self, id: NodeId) {
549        if let Some(node) = self.nodes.get_mut(id.0 as usize) {
550            node.generation = self.current_gen;
551            node.dirty_gen = 0;
552        }
553    }
554
555    /// Clean all nodes and advance the generation.
556    pub fn clean_all(&mut self) {
557        self.current_gen = self.current_gen.wrapping_add(1);
558        if self.current_gen == 0 {
559            self.current_gen = 1; // Skip 0 (dead sentinel).
560        }
561        for node in &mut self.nodes {
562            if node.generation != 0 {
563                node.generation = self.current_gen;
564                node.dirty_gen = 0;
565            }
566        }
567        self.pending_dirty.clear();
568    }
569
570    /// Get the constraint hash for a node.
571    #[must_use]
572    pub fn constraint_hash(&self, id: NodeId) -> Option<u64> {
573        self.nodes
574            .get(id.0 as usize)
575            .filter(|n| n.generation != 0)
576            .map(|n| n.constraint_hash)
577    }
578
579    /// Get the content hash for a node.
580    #[must_use]
581    pub fn content_hash(&self, id: NodeId) -> Option<u64> {
582        self.nodes
583            .get(id.0 as usize)
584            .filter(|n| n.generation != 0)
585            .map(|n| n.content_hash)
586    }
587
588    /// Get the style hash for a node.
589    #[must_use]
590    pub fn style_hash(&self, id: NodeId) -> Option<u64> {
591        self.nodes
592            .get(id.0 as usize)
593            .filter(|n| n.generation != 0)
594            .map(|n| n.style_hash)
595    }
596
597    /// Iterate all live, dirty node IDs.
598    pub fn dirty_nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
599        self.nodes
600            .iter()
601            .enumerate()
602            .filter(|(_, n)| n.generation != 0 && n.is_dirty())
603            .map(|(i, _)| NodeId(i as u32))
604    }
605
606    /// Count of currently dirty nodes.
607    #[must_use]
608    pub fn dirty_count(&self) -> usize {
609        self.nodes
610            .iter()
611            .filter(|n| n.generation != 0 && n.is_dirty())
612            .count()
613    }
614
615    /// Get forward dependencies for a node (what it depends on).
616    #[must_use]
617    pub fn dependencies(&self, id: NodeId) -> &[NodeId] {
618        let idx = id.0 as usize;
619        if idx >= self.nodes.len() || self.nodes[idx].generation == 0 {
620            return &[];
621        }
622        &self.fwd_adj[idx]
623    }
624
625    /// Get reverse dependencies for a node (what depends on it).
626    #[must_use]
627    pub fn dependents(&self, id: NodeId) -> &[NodeId] {
628        let idx = id.0 as usize;
629        if idx >= self.nodes.len() || self.nodes[idx].generation == 0 {
630            return &[];
631        }
632        &self.rev_adj[idx]
633    }
634
635    /// Invalidate all nodes (equivalent to full layout).
636    /// Used as a fallback when incremental is not possible.
637    pub fn invalidate_all(&mut self) {
638        for (i, node) in self.nodes.iter_mut().enumerate() {
639            if node.generation != 0 {
640                node.dirty_gen = self.current_gen;
641                self.pending_dirty.push(NodeId(i as u32));
642            }
643        }
644    }
645}
646
647impl Default for DepGraph {
648    fn default() -> Self {
649        Self::new()
650    }
651}
652
653// ============================================================================
654// Tests
655// ============================================================================
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[test]
662    fn add_node_returns_sequential_ids() {
663        let mut g = DepGraph::new();
664        let a = g.add_node();
665        let b = g.add_node();
666        let c = g.add_node();
667        assert_eq!(a.raw(), 0);
668        assert_eq!(b.raw(), 1);
669        assert_eq!(c.raw(), 2);
670        assert_eq!(g.node_count(), 3);
671    }
672
673    #[test]
674    fn remove_node_recycles_slot() {
675        let mut g = DepGraph::new();
676        let a = g.add_node();
677        let _b = g.add_node();
678        g.remove_node(a);
679        assert_eq!(g.node_count(), 1);
680        let c = g.add_node();
681        assert_eq!(c.raw(), 0); // Recycled slot.
682        assert_eq!(g.node_count(), 2);
683    }
684
685    #[test]
686    fn add_edge_creates_dependency() {
687        let mut g = DepGraph::new();
688        let a = g.add_node();
689        let b = g.add_node();
690        g.add_edge(a, b).unwrap();
691        assert_eq!(g.dependencies(a), &[b]);
692        assert_eq!(g.dependents(b), &[a]);
693        assert_eq!(g.edge_count(), 1);
694    }
695
696    #[test]
697    fn self_loop_detected() {
698        let mut g = DepGraph::new();
699        let a = g.add_node();
700        let err = g.add_edge(a, a).unwrap_err();
701        assert_eq!(err, CycleError { from: a, to: a });
702    }
703
704    #[test]
705    fn two_node_cycle_detected() {
706        let mut g = DepGraph::new();
707        let a = g.add_node();
708        let b = g.add_node();
709        g.add_edge(a, b).unwrap();
710        let err = g.add_edge(b, a).unwrap_err();
711        assert_eq!(err, CycleError { from: b, to: a });
712    }
713
714    #[test]
715    fn three_node_cycle_detected() {
716        let mut g = DepGraph::new();
717        let a = g.add_node();
718        let b = g.add_node();
719        let c = g.add_node();
720        g.add_edge(a, b).unwrap();
721        g.add_edge(b, c).unwrap();
722        let err = g.add_edge(c, a).unwrap_err();
723        assert_eq!(err, CycleError { from: c, to: a });
724    }
725
726    #[test]
727    fn dag_allows_diamond() {
728        // A → B, A → C, B → D, C → D (diamond, no cycle).
729        let mut g = DepGraph::new();
730        let a = g.add_node();
731        let b = g.add_node();
732        let c = g.add_node();
733        let d = g.add_node();
734        g.add_edge(a, b).unwrap();
735        g.add_edge(a, c).unwrap();
736        g.add_edge(b, d).unwrap();
737        g.add_edge(c, d).unwrap();
738        assert_eq!(g.edge_count(), 4);
739    }
740
741    #[test]
742    fn mark_changed_deduplicates() {
743        let mut g = DepGraph::new();
744        let a = g.add_node();
745        g.mark_changed(a, InputKind::Constraint, 42);
746        assert!(g.is_dirty(a));
747
748        // Same hash → no additional dirtying.
749        g.clean(a);
750        g.mark_changed(a, InputKind::Constraint, 42);
751        assert!(!g.is_dirty(a)); // Hash unchanged.
752    }
753
754    #[test]
755    fn mark_changed_different_hash() {
756        let mut g = DepGraph::new();
757        let a = g.add_node();
758        g.mark_changed(a, InputKind::Constraint, 42);
759        g.clean(a);
760        g.mark_changed(a, InputKind::Constraint, 99); // Different hash.
761        assert!(g.is_dirty(a));
762    }
763
764    #[test]
765    fn propagate_single_node() {
766        let mut g = DepGraph::new();
767        let a = g.add_node();
768        g.mark_dirty(a);
769        let dirty = g.propagate();
770        assert_eq!(dirty, vec![a]);
771    }
772
773    #[test]
774    fn propagate_parent_to_child() {
775        let mut g = DepGraph::new();
776        let parent = g.add_node();
777        let child = g.add_node();
778        g.add_edge(child, parent).unwrap(); // child depends on parent
779        g.set_parent(child, parent);
780
781        g.mark_dirty(parent);
782        let dirty = g.propagate();
783        assert!(dirty.contains(&parent));
784        assert!(dirty.contains(&child));
785    }
786
787    #[test]
788    fn propagate_chain() {
789        // A → B → C: dirtying A should dirty B and C.
790        let mut g = DepGraph::new();
791        let a = g.add_node();
792        let b = g.add_node();
793        let c = g.add_node();
794        g.add_edge(b, a).unwrap(); // B depends on A
795        g.add_edge(c, b).unwrap(); // C depends on B
796        g.set_parent(b, a);
797        g.set_parent(c, b);
798
799        g.mark_dirty(a);
800        let dirty = g.propagate();
801        assert_eq!(dirty.len(), 3);
802        assert!(dirty.contains(&a));
803        assert!(dirty.contains(&b));
804        assert!(dirty.contains(&c));
805    }
806
807    #[test]
808    fn propagate_only_affected_subtree() {
809        // A → B, A → C. D is independent.
810        let mut g = DepGraph::new();
811        let a = g.add_node();
812        let b = g.add_node();
813        let c = g.add_node();
814        let d = g.add_node();
815        g.add_edge(b, a).unwrap();
816        g.add_edge(c, a).unwrap();
817
818        g.mark_dirty(a);
819        let dirty = g.propagate();
820        assert!(dirty.contains(&a));
821        assert!(dirty.contains(&b));
822        assert!(dirty.contains(&c));
823        assert!(!dirty.contains(&d));
824    }
825
826    #[test]
827    fn propagate_diamond_deduplicates() {
828        // D depends on B and C, both depend on A.
829        let mut g = DepGraph::new();
830        let a = g.add_node();
831        let b = g.add_node();
832        let c = g.add_node();
833        let d = g.add_node();
834        g.add_edge(b, a).unwrap();
835        g.add_edge(c, a).unwrap();
836        g.add_edge(d, b).unwrap();
837        g.add_edge(d, c).unwrap();
838
839        g.mark_dirty(a);
840        let dirty = g.propagate();
841        assert_eq!(dirty.len(), 4);
842        // Each node appears exactly once.
843        let unique: std::collections::HashSet<_> = dirty.iter().collect();
844        assert_eq!(unique.len(), 4);
845    }
846
847    #[test]
848    fn clean_all_resets() {
849        let mut g = DepGraph::new();
850        let a = g.add_node();
851        let b = g.add_node();
852        g.add_edge(b, a).unwrap();
853        g.mark_dirty(a);
854        g.propagate();
855        assert!(g.is_dirty(a));
856        assert!(g.is_dirty(b));
857
858        g.clean_all();
859        assert!(!g.is_dirty(a));
860        assert!(!g.is_dirty(b));
861        assert_eq!(g.dirty_count(), 0);
862    }
863
864    #[test]
865    fn invalidate_all_dirties_everything() {
866        let mut g = DepGraph::new();
867        let a = g.add_node();
868        let b = g.add_node();
869        let c = g.add_node();
870
871        g.invalidate_all();
872        let dirty = g.propagate();
873        assert_eq!(dirty.len(), 3);
874        assert!(dirty.contains(&a));
875        assert!(dirty.contains(&b));
876        assert!(dirty.contains(&c));
877    }
878
879    #[test]
880    fn parent_child_relationship() {
881        let mut g = DepGraph::new();
882        let a = g.add_node();
883        let b = g.add_node();
884        assert_eq!(g.parent(a), None);
885        g.set_parent(b, a);
886        assert_eq!(g.parent(b), Some(a));
887    }
888
889    #[test]
890    fn input_hashes_stored_independently() {
891        let mut g = DepGraph::new();
892        let a = g.add_node();
893        g.mark_changed(a, InputKind::Constraint, 1);
894        g.mark_changed(a, InputKind::Content, 2);
895        g.mark_changed(a, InputKind::Style, 3);
896
897        assert_eq!(g.constraint_hash(a), Some(1));
898        assert_eq!(g.content_hash(a), Some(2));
899        assert_eq!(g.style_hash(a), Some(3));
900    }
901
902    #[test]
903    fn dead_node_is_not_dirty() {
904        let mut g = DepGraph::new();
905        let a = g.add_node();
906        g.mark_dirty(a);
907        g.remove_node(a);
908        assert!(!g.is_dirty(a));
909    }
910
911    #[test]
912    fn propagate_skips_dead_nodes() {
913        let mut g = DepGraph::new();
914        let a = g.add_node();
915        let b = g.add_node();
916        let c = g.add_node();
917        g.add_edge(b, a).unwrap();
918        g.add_edge(c, b).unwrap();
919
920        // Remove B from the chain.
921        g.remove_node(b);
922        g.mark_dirty(a);
923        let dirty = g.propagate();
924        // B is dead, so C should not be reached (B's reverse edges are cleared).
925        assert!(dirty.contains(&a));
926        assert!(!dirty.contains(&c));
927    }
928
929    #[test]
930    fn propagate_empty_returns_empty() {
931        let mut g = DepGraph::new();
932        let _a = g.add_node();
933        let dirty = g.propagate();
934        assert!(dirty.is_empty());
935    }
936
937    #[test]
938    fn large_tree_propagation() {
939        // 1000-node tree: root → 10 children → 10 grandchildren each.
940        let mut g = DepGraph::with_capacity(1000, 1000);
941        let root = g.add_node();
942        let mut leaves = Vec::new();
943
944        for _ in 0..10 {
945            let child = g.add_node();
946            g.add_edge(child, root).unwrap();
947            g.set_parent(child, root);
948
949            for _ in 0..10 {
950                let grandchild = g.add_node();
951                g.add_edge(grandchild, child).unwrap();
952                g.set_parent(grandchild, child);
953                leaves.push(grandchild);
954            }
955        }
956        assert_eq!(g.node_count(), 111); // 1 + 10 + 100
957
958        // Dirty root → all 111 nodes dirty.
959        g.mark_dirty(root);
960        let dirty = g.propagate();
961        assert_eq!(dirty.len(), 111);
962
963        // Clean and dirty one leaf → only that leaf.
964        g.clean_all();
965        g.mark_dirty(leaves[42]);
966        let dirty = g.propagate();
967        assert_eq!(dirty.len(), 1);
968        assert_eq!(dirty[0], leaves[42]);
969    }
970
971    #[test]
972    fn node_size_under_64_bytes() {
973        assert!(
974            std::mem::size_of::<DepNode>() <= 64,
975            "DepNode is {} bytes, exceeds 64-byte budget",
976            std::mem::size_of::<DepNode>(),
977        );
978    }
979
980    #[test]
981    fn node_size_exactly_40_bytes() {
982        // 4+4+8+8+8+4 = 36 raw, but u64 alignment pads to 40.
983        assert_eq!(
984            std::mem::size_of::<DepNode>(),
985            40,
986            "DepNode should be 40 bytes, got {}",
987            std::mem::size_of::<DepNode>(),
988        );
989    }
990
991    #[test]
992    fn multiple_input_changes_single_propagation() {
993        let mut g = DepGraph::new();
994        let a = g.add_node();
995        let b = g.add_node();
996        g.add_edge(b, a).unwrap();
997
998        // Change constraint AND content before propagating.
999        g.mark_changed(a, InputKind::Constraint, 10);
1000        g.mark_changed(a, InputKind::Content, 20);
1001        let dirty = g.propagate();
1002        assert_eq!(dirty.len(), 2); // a and b
1003    }
1004
1005    #[test]
1006    fn propagate_dfs_preorder() {
1007        // Tree: R → A → (B, C). Should visit R, A, B, C in that order.
1008        let mut g = DepGraph::new();
1009        let r = g.add_node(); // 0
1010        let a = g.add_node(); // 1
1011        let b = g.add_node(); // 2
1012        let c = g.add_node(); // 3
1013        g.add_edge(a, r).unwrap();
1014        g.add_edge(b, a).unwrap();
1015        g.add_edge(c, a).unwrap();
1016        g.set_parent(a, r);
1017        g.set_parent(b, a);
1018        g.set_parent(c, a);
1019
1020        g.mark_dirty(r);
1021        let dirty = g.propagate();
1022        assert_eq!(dirty.len(), 4);
1023        // DFS pre-order from root: R first, then A, then B and C.
1024        assert_eq!(dirty[0], r);
1025        assert_eq!(dirty[1], a);
1026        // B and C are siblings, ordered by NodeId.
1027        assert_eq!(dirty[2], b);
1028        assert_eq!(dirty[3], c);
1029    }
1030
1031    #[test]
1032    fn dirty_count_accurate() {
1033        let mut g = DepGraph::new();
1034        let a = g.add_node();
1035        let b = g.add_node();
1036        let c = g.add_node();
1037        assert_eq!(g.dirty_count(), 0);
1038
1039        g.mark_dirty(a);
1040        g.mark_dirty(b);
1041        g.propagate();
1042        assert_eq!(g.dirty_count(), 2);
1043
1044        g.clean(a);
1045        assert_eq!(g.dirty_count(), 1);
1046
1047        g.clean_all();
1048        assert_eq!(g.dirty_count(), 0);
1049
1050        // Suppress unused variable warning.
1051        let _ = c;
1052    }
1053
1054    #[test]
1055    fn dependencies_and_dependents_api() {
1056        let mut g = DepGraph::new();
1057        let a = g.add_node();
1058        let b = g.add_node();
1059        let c = g.add_node();
1060        g.add_edge(b, a).unwrap(); // b depends on a
1061        g.add_edge(c, a).unwrap(); // c depends on a
1062
1063        assert_eq!(g.dependencies(b), &[a]);
1064        assert_eq!(g.dependencies(c), &[a]);
1065        assert_eq!(g.dependents(a).len(), 2);
1066    }
1067}