Skip to main content

kanban_core/graph/
dag.rs

1use serde::{Deserialize, Deserializer, Serialize};
2
3use super::core::EdgeStore;
4use super::edge::Edge;
5use super::error::GraphError;
6use super::traits::{Cascadable, Directed, EdgeSet, Graph};
7
8/// Directed acyclic graph generic over any `E: Edge`.
9///
10/// Rejects self-references and any edge whose insertion would
11/// create a cycle in the active-edge subgraph. Wraps an
12/// [`EdgeStore<E>`] to inherit archive / unarchive semantics for
13/// soft-delete cascades.
14///
15/// `Deserialize` runs the DAG invariants against the active subset
16/// of loaded edges; a corrupted file with a cycle or self-reference
17/// fails to load up front rather than silently rehydrating into an
18/// invariant-violating state.
19///
20/// The graph machinery is fully generic: external code can
21/// instantiate `DagGraph<MyEdge>` for any `MyEdge: Edge` without
22/// needing to modify this crate. The kanban-domain `DependencyGraph`
23/// uses three concrete instantiations
24/// (`DagGraph<SpawnsEdge>` / `DagGraph<BlocksEdge>` / etc.) but the
25/// machinery itself is open for extension.
26#[derive(Debug, Clone, PartialEq, Serialize)]
27pub struct DagGraph<E> {
28    #[serde(flatten)]
29    store: EdgeStore<E>,
30}
31
32impl<E> Default for DagGraph<E> {
33    fn default() -> Self {
34        Self {
35            store: EdgeStore::new(),
36        }
37    }
38}
39
40impl<'de, E> Deserialize<'de> for DagGraph<E>
41where
42    E: Edge + Clone + Deserialize<'de>,
43{
44    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45        let store: EdgeStore<E> = EdgeStore::deserialize(deserializer)?;
46        let mut graph: DagGraph<E> = DagGraph::default();
47        for edge in store.edges() {
48            graph
49                .add_edge_with_metadata(edge.clone())
50                .map_err(serde::de::Error::custom)?;
51        }
52        Ok(graph)
53    }
54}
55
56impl<E: Edge> DagGraph<E> {
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Borrow the raw underlying edge list (active + archived).
62    /// Persistence layers use this to serialise; size and membership
63    /// queries go through the [`EdgeSet`] trait surface.
64    pub fn edges(&self) -> &[E] {
65        self.store.edges()
66    }
67
68    /// Push an edge while preserving caller-supplied metadata.
69    ///
70    /// Runs the same self-reference, duplicate, and cycle invariants
71    /// as the trait's [`Graph::add_edge`]:
72    /// - self-references are rejected always;
73    /// - active duplicates (an existing active `source -> target`)
74    ///   are rejected always; the duplicate check ignores archived
75    ///   edges so re-adding after archive succeeds;
76    /// - cycles are rejected when the edge is active, ignored
77    ///   when it is archived (archived edges are not part of the
78    ///   active DAG and don't constrain new mutations).
79    ///
80    /// Load paths use this to rehydrate stored edges and surface
81    /// corrupt-DAG state as a hard load failure.
82    pub fn add_edge_with_metadata(&mut self, edge: E) -> Result<(), GraphError> {
83        if edge.source() == edge.target() {
84            return Err(GraphError::SelfReference);
85        }
86        if edge.is_active() {
87            if self
88                .store
89                .outgoing_active(edge.source())
90                .any(|e| e.target() == edge.target())
91            {
92                return Err(GraphError::Duplicate);
93            }
94            let adj = self.store.adjacency_list();
95            if super::algorithms::would_create_cycle(&adj, edge.source(), edge.target()) {
96                return Err(GraphError::Cycle);
97            }
98        }
99        self.store.add_edge(edge);
100        Ok(())
101    }
102
103    /// Transitive successors of `node` (descendants).
104    pub fn descendants(&self, node: E::NodeId) -> Vec<E::NodeId> {
105        let mut out = Vec::new();
106        let mut stack = vec![node];
107        let mut seen = std::collections::HashSet::new();
108        seen.insert(node);
109        while let Some(n) = stack.pop() {
110            for next in self.outgoing(n) {
111                if seen.insert(next) {
112                    out.push(next);
113                    stack.push(next);
114                }
115            }
116        }
117        out
118    }
119
120    /// Transitive predecessors of `node` (ancestors).
121    pub fn ancestors(&self, node: E::NodeId) -> Vec<E::NodeId> {
122        let mut out = Vec::new();
123        let mut stack = vec![node];
124        let mut seen = std::collections::HashSet::new();
125        seen.insert(node);
126        while let Some(n) = stack.pop() {
127            for prev in self.incoming(n) {
128                if seen.insert(prev) {
129                    out.push(prev);
130                    stack.push(prev);
131                }
132            }
133        }
134        out
135    }
136}
137
138impl<E: Edge> Cascadable for DagGraph<E> {
139    fn archive_node(&mut self, node: E::NodeId) {
140        self.store.archive_node(node);
141    }
142    fn unarchive_node(&mut self, node: E::NodeId) {
143        self.store.unarchive_node(node);
144    }
145    fn remove_node(&mut self, node: E::NodeId) {
146        self.store.remove_node(node);
147    }
148}
149
150impl<E: Edge> EdgeSet for DagGraph<E> {
151    fn len(&self) -> usize {
152        self.store.edge_count()
153    }
154    fn active_len(&self) -> usize {
155        self.store.active_edge_count()
156    }
157    /// Directed active-only membership: source-to-target ordering
158    /// matters. Aligned with `Graph::contains_edge`.
159    fn contains(&self, a: E::NodeId, b: E::NodeId) -> bool {
160        self.store.outgoing_active(a).any(|e| e.target() == b)
161    }
162    /// Directed any-state membership including archived edges.
163    fn contains_archived(&self, a: E::NodeId, b: E::NodeId) -> bool {
164        self.store
165            .edges()
166            .iter()
167            .any(|e| e.source() == a && e.target() == b)
168    }
169}
170
171impl<E: Edge> Graph for DagGraph<E> {
172    type NodeId = E::NodeId;
173
174    fn add_edge(&mut self, from: E::NodeId, to: E::NodeId) -> Result<(), GraphError> {
175        // Synthesise an E with per-kind default metadata via the
176        // trait constructor. Callers that need to set non-default
177        // metadata construct the concrete struct (e.g.
178        // `BlocksEdge::new(..., Severity::High)`) and push it via
179        // `add_edge_with_metadata`.
180        self.add_edge_with_metadata(E::from_endpoints(from, to))
181    }
182
183    fn remove_edge(&mut self, from: E::NodeId, to: E::NodeId) -> Result<(), GraphError> {
184        if self.store.remove_directed_edge(from, to) {
185            Ok(())
186        } else {
187            Err(GraphError::EdgeNotFound)
188        }
189    }
190
191    fn contains_edge(&self, from: E::NodeId, to: E::NodeId) -> bool {
192        self.store.outgoing_active(from).any(|e| e.target() == to)
193    }
194}
195
196impl<E: Edge> Directed for DagGraph<E> {
197    fn outgoing(&self, node: E::NodeId) -> Vec<E::NodeId> {
198        self.store
199            .outgoing_active(node)
200            .map(|e| e.target())
201            .collect()
202    }
203
204    fn incoming(&self, node: E::NodeId) -> Vec<E::NodeId> {
205        self.store
206            .incoming_active(node)
207            .map(|e| e.source())
208            .collect()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::graph::edge::EdgeBase;
216    use uuid::Uuid;
217
218    fn ids() -> (Uuid, Uuid, Uuid) {
219        (Uuid::new_v4(), Uuid::new_v4(), Uuid::new_v4())
220    }
221
222    fn add(g: &mut DagGraph<EdgeBase>, a: Uuid, b: Uuid) -> Result<(), GraphError> {
223        g.add_edge_with_metadata(EdgeBase::new(a, b))
224    }
225
226    #[test]
227    fn test_new_dag_is_empty() {
228        let g: DagGraph<EdgeBase> = DagGraph::new();
229        assert_eq!(g.len(), 0);
230        assert_eq!(g.active_len(), 0);
231    }
232
233    #[test]
234    fn test_add_edge_with_metadata_creates_outgoing_and_incoming() {
235        let (a, b, _) = ids();
236        let mut g: DagGraph<EdgeBase> = DagGraph::new();
237        add(&mut g, a, b).unwrap();
238        assert_eq!(g.outgoing(a), vec![b]);
239        assert_eq!(g.incoming(b), vec![a]);
240    }
241
242    #[test]
243    fn test_add_edge_with_metadata_self_reference_returns_error() {
244        let (a, _, _) = ids();
245        let mut g: DagGraph<EdgeBase> = DagGraph::new();
246        assert_eq!(add(&mut g, a, a), Err(GraphError::SelfReference));
247    }
248
249    #[test]
250    fn test_add_edge_with_metadata_creating_cycle_returns_error() {
251        let (a, b, c) = ids();
252        let mut g: DagGraph<EdgeBase> = DagGraph::new();
253        add(&mut g, a, b).unwrap();
254        add(&mut g, b, c).unwrap();
255        assert_eq!(add(&mut g, c, a), Err(GraphError::Cycle));
256    }
257
258    #[test]
259    fn test_remove_edge_existing_succeeds() {
260        let (a, b, _) = ids();
261        let mut g: DagGraph<EdgeBase> = DagGraph::new();
262        add(&mut g, a, b).unwrap();
263        assert_eq!(g.remove_edge(a, b), Ok(()));
264        assert!(g.outgoing(a).is_empty());
265    }
266
267    #[test]
268    fn test_remove_edge_missing_returns_edge_not_found() {
269        let (a, b, _) = ids();
270        let mut g: DagGraph<EdgeBase> = DagGraph::new();
271        assert_eq!(g.remove_edge(a, b), Err(GraphError::EdgeNotFound));
272    }
273
274    #[test]
275    fn test_contains_edge_distinguishes_direction() {
276        let (a, b, _) = ids();
277        let mut g: DagGraph<EdgeBase> = DagGraph::new();
278        add(&mut g, a, b).unwrap();
279        assert!(g.contains_edge(a, b));
280        assert!(!g.contains_edge(b, a));
281    }
282
283    #[test]
284    fn test_archive_node_removes_from_active_view() {
285        let (a, b, _) = ids();
286        let mut g: DagGraph<EdgeBase> = DagGraph::new();
287        add(&mut g, a, b).unwrap();
288        g.archive_node(b);
289        assert!(g.outgoing(a).is_empty());
290        assert_eq!(g.len(), 1);
291        assert_eq!(g.active_len(), 0);
292    }
293
294    #[test]
295    fn test_unarchive_node_restores_active_view() {
296        let (a, b, _) = ids();
297        let mut g: DagGraph<EdgeBase> = DagGraph::new();
298        add(&mut g, a, b).unwrap();
299        g.archive_node(b);
300        g.unarchive_node(b);
301        assert_eq!(g.outgoing(a), vec![b]);
302    }
303
304    #[test]
305    fn test_remove_node_deletes_all_involved_edges() {
306        let (a, b, c) = ids();
307        let mut g: DagGraph<EdgeBase> = DagGraph::new();
308        add(&mut g, a, b).unwrap();
309        add(&mut g, b, c).unwrap();
310        g.remove_node(b);
311        assert_eq!(g.len(), 0);
312    }
313
314    #[test]
315    fn test_descendants_returns_transitive_successors() {
316        let (a, b, c) = ids();
317        let d = Uuid::new_v4();
318        let mut g: DagGraph<EdgeBase> = DagGraph::new();
319        add(&mut g, a, b).unwrap();
320        add(&mut g, b, c).unwrap();
321        add(&mut g, c, d).unwrap();
322        let mut got = g.descendants(a);
323        got.sort();
324        let mut expected = vec![b, c, d];
325        expected.sort();
326        assert_eq!(got, expected);
327    }
328
329    #[test]
330    fn test_ancestors_returns_transitive_predecessors() {
331        let (a, b, c) = ids();
332        let mut g: DagGraph<EdgeBase> = DagGraph::new();
333        add(&mut g, a, b).unwrap();
334        add(&mut g, b, c).unwrap();
335        let mut got = g.ancestors(c);
336        got.sort();
337        let mut expected = vec![a, b];
338        expected.sort();
339        assert_eq!(got, expected);
340    }
341
342    #[test]
343    fn test_add_active_duplicate_returns_duplicate_error() {
344        let (a, b, _) = ids();
345        let mut g: DagGraph<EdgeBase> = DagGraph::new();
346        add(&mut g, a, b).unwrap();
347        assert_eq!(
348            add(&mut g, a, b),
349            Err(GraphError::Duplicate),
350            "second active a->b must be rejected as duplicate"
351        );
352        assert_eq!(
353            g.outgoing(a),
354            vec![b],
355            "rejected duplicate must not appear in adjacency"
356        );
357    }
358
359    #[test]
360    fn test_add_reverse_orientation_is_not_a_duplicate() {
361        let (a, b, _) = ids();
362        let mut g: DagGraph<EdgeBase> = DagGraph::new();
363        add(&mut g, a, b).unwrap();
364        // a->b exists; b->a is a different directed edge — but the DAG
365        // also rejects it as a cycle (a->b->a). Pinning the precedence:
366        // cycle wins over duplicate because cycle covers the structural
367        // invariant.
368        assert_eq!(
369            add(&mut g, b, a),
370            Err(GraphError::Cycle),
371            "reverse orientation is a cycle in a DAG, not a duplicate"
372        );
373    }
374
375    #[test]
376    fn test_re_add_after_archive_succeeds_and_keeps_both_records() {
377        let (a, b, _) = ids();
378        let mut g: DagGraph<EdgeBase> = DagGraph::new();
379        add(&mut g, a, b).unwrap();
380        g.archive_node(a);
381        // Archived edge no longer counts toward the duplicate check.
382        add(&mut g, a, b).unwrap();
383        assert_eq!(g.active_len(), 1, "one fresh active edge");
384        assert_eq!(g.len(), 2, "archive record is preserved alongside");
385    }
386
387    #[test]
388    fn test_archived_edge_is_ignored_for_cycle_check() {
389        let (a, b, _) = ids();
390        let mut g: DagGraph<EdgeBase> = DagGraph::new();
391        add(&mut g, a, b).unwrap();
392        g.archive_node(a);
393        assert_eq!(add(&mut g, b, a), Ok(()));
394    }
395
396    #[test]
397    fn test_deserialize_rejects_cycle_in_active_edges() {
398        let (a, b, c) = ids();
399        let now = chrono::Utc::now();
400        let json = serde_json::json!({
401            "edges": [
402                {"source": a, "target": b, "created_at": now, "archived_at": null},
403                {"source": b, "target": c, "created_at": now, "archived_at": null},
404                {"source": c, "target": a, "created_at": now, "archived_at": null},
405            ]
406        });
407        let result: Result<DagGraph<EdgeBase>, _> = serde_json::from_value(json);
408        let err = result.expect_err("a 3-cycle must fail to deserialize");
409        assert!(
410            err.to_string().to_lowercase().contains("cycle"),
411            "deserialize error should name the cycle invariant: {err}"
412        );
413    }
414
415    #[test]
416    fn test_deserialize_rejects_self_reference() {
417        let a = Uuid::new_v4();
418        let now = chrono::Utc::now();
419        let json = serde_json::json!({
420            "edges": [
421                {"source": a, "target": a, "created_at": now, "archived_at": null},
422            ]
423        });
424        let result: Result<DagGraph<EdgeBase>, _> = serde_json::from_value(json);
425        let err = result.expect_err("self-reference must fail to deserialize");
426        assert!(
427            err.to_string().to_lowercase().contains("self"),
428            "deserialize error should name the self-reference invariant: {err}"
429        );
430    }
431
432    /// The graph machinery is parameterised over the `Edge::NodeId`
433    /// associated type, not pinned to `Uuid`. This test pins the
434    /// abstraction unlock by instantiating a `DagGraph` over an edge
435    /// whose endpoints are `u32` rather than `Uuid` and exercising
436    /// cycle / reachability over it. If the unlock ever regresses
437    /// (e.g. someone re-hardcodes `Uuid` in algorithms.rs or
438    /// EdgeStore), this test fails to compile.
439    #[test]
440    fn test_dag_graph_works_with_non_uuid_node_id() {
441        use crate::graph::edge::EdgeBase;
442        let mut g: DagGraph<EdgeBase<u32>> = DagGraph::new();
443        g.add_edge_with_metadata(EdgeBase::new(1u32, 2u32)).unwrap();
444        g.add_edge_with_metadata(EdgeBase::new(2u32, 3u32)).unwrap();
445        assert_eq!(
446            g.add_edge_with_metadata(EdgeBase::new(3u32, 1u32)),
447            Err(GraphError::Cycle),
448            "cycle detection works over u32 node ids"
449        );
450        assert_eq!(g.outgoing(1), vec![2u32]);
451        assert_eq!(g.incoming(3), vec![2u32]);
452        let mut desc = g.descendants(1);
453        desc.sort();
454        assert_eq!(desc, vec![2u32, 3u32]);
455    }
456
457    #[test]
458    fn test_deserialize_accepts_archived_edge_completing_cycle() {
459        let (a, b, c) = ids();
460        let now = chrono::Utc::now();
461        let json = serde_json::json!({
462            "edges": [
463                {"source": a, "target": b, "created_at": now, "archived_at": null},
464                {"source": b, "target": c, "created_at": now, "archived_at": null},
465                {"source": c, "target": a, "created_at": now, "archived_at": now},
466            ]
467        });
468        let graph: DagGraph<EdgeBase> =
469            serde_json::from_value(json).expect("cycle through archived edge must be loadable");
470        assert_eq!(graph.len(), 3);
471        assert_eq!(graph.active_len(), 2);
472    }
473}