Skip to main content

kanban_core/graph/
core.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use super::algorithms;
5use super::edge::Edge;
6
7/// Generic edge-list container over any `E: Edge`.
8///
9/// Stores edges as a flat list for efficient serialization. Exposes
10/// only kind-agnostic operations: anything that depends on direction
11/// or per-kind matching semantics (e.g. "is this an undirected edge
12/// between a and b in either order?") lives on the sub-graph type
13/// that wraps `EdgeStore`.
14///
15/// `E: Edge` lets node-level cascade ops (`archive_node`,
16/// `unarchive_node`, `remove_node`) and traversal queries
17/// (`outgoing`, `incoming`, `adjacency_list`) work without knowing
18/// `E`'s concrete metadata. The graph machinery is reusable across
19/// any kanban-domain or external kind that satisfies the trait.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct EdgeStore<E> {
22    edges: Vec<E>,
23}
24
25impl<E> Default for EdgeStore<E> {
26    fn default() -> Self {
27        Self { edges: Vec::new() }
28    }
29}
30
31impl<E> EdgeStore<E> {
32    /// Create an empty edge store.
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Push an edge onto the store, skipping structural invariants.
38    ///
39    /// Crate-private: the only legitimate callers are
40    /// `DagGraph::add_edge_with_metadata` and
41    /// `UndirectedGraph::add_edge_with_metadata`, both of which run
42    /// the relevant invariants (self-ref, duplicate, cycle) before
43    /// pushing. Exposing this further would let an external wrapper
44    /// silently bypass those checks — the trait surface (`Graph`,
45    /// `Directed`, `Undirected`) is the public entry point.
46    pub(crate) fn add_edge(&mut self, edge: E) {
47        self.edges.push(edge);
48    }
49
50    /// Retain only edges matching `f`. The kind-aware
51    /// `remove_directed_edge` / `remove_undirected_edge` helpers
52    /// below build on this; sub-graphs that need other matching
53    /// semantics can call `retain` directly.
54    pub fn retain<F: FnMut(&E) -> bool>(&mut self, f: F) {
55        self.edges.retain(f);
56    }
57
58    /// Borrow every edge (active + archived).
59    pub fn edges(&self) -> &[E] {
60        &self.edges
61    }
62
63    /// Total edge count (active + archived).
64    pub fn edge_count(&self) -> usize {
65        self.edges.len()
66    }
67}
68
69impl<E: Edge> EdgeStore<E> {
70    /// Remove every edge involving `node` (hard-delete cascade).
71    pub fn remove_node(&mut self, node_id: E::NodeId) {
72        self.edges.retain(|e| !e.involves(node_id));
73    }
74
75    /// Archive every edge involving `node` (soft-delete cascade).
76    pub fn archive_node(&mut self, node_id: E::NodeId) {
77        for edge in &mut self.edges {
78            if edge.involves(node_id) {
79                edge.archive();
80            }
81        }
82    }
83
84    /// Unarchive every edge involving `node`.
85    pub fn unarchive_node(&mut self, node_id: E::NodeId) {
86        for edge in &mut self.edges {
87            if edge.involves(node_id) {
88                edge.unarchive();
89            }
90        }
91    }
92
93    /// Remove the single **active** edge whose `source == source` and
94    /// `target == target` exactly (directed-graph semantics). Archived
95    /// edges with the same endpoints are preserved — they're history,
96    /// not part of the current view, and a remove of the current edge
97    /// must not silently destroy the history record. Returns `true`
98    /// iff an active edge was removed.
99    pub fn remove_directed_edge(&mut self, source: E::NodeId, target: E::NodeId) -> bool {
100        let before = self.edges.len();
101        self.edges
102            .retain(|e| !(e.is_active() && e.source() == source && e.target() == target));
103        self.edges.len() < before
104    }
105
106    /// Remove any **active** edge whose endpoints are `{a, b}`
107    /// regardless of ordering (undirected-graph semantics). Archived
108    /// edges are preserved. Returns `true` iff at least one active
109    /// edge was removed.
110    pub fn remove_undirected_edge(&mut self, a: E::NodeId, b: E::NodeId) -> bool {
111        let before = self.edges.len();
112        self.edges.retain(|e| {
113            !(e.is_active()
114                && ((e.source() == a && e.target() == b) || (e.source() == b && e.target() == a)))
115        });
116        self.edges.len() < before
117    }
118
119    /// Iterate every outgoing edge from `node` (source == node).
120    pub fn outgoing(&self, node_id: E::NodeId) -> impl Iterator<Item = &E> {
121        self.edges.iter().filter(move |e| e.source() == node_id)
122    }
123
124    /// Iterate every incoming edge to `node` (target == node).
125    pub fn incoming(&self, node_id: E::NodeId) -> impl Iterator<Item = &E> {
126        self.edges.iter().filter(move |e| e.target() == node_id)
127    }
128
129    /// Iterate active outgoing edges from `node`.
130    pub fn outgoing_active(&self, node_id: E::NodeId) -> impl Iterator<Item = &E> {
131        self.edges
132            .iter()
133            .filter(move |e| e.source() == node_id && e.is_active())
134    }
135
136    /// Iterate active incoming edges to `node`.
137    pub fn incoming_active(&self, node_id: E::NodeId) -> impl Iterator<Item = &E> {
138        self.edges
139            .iter()
140            .filter(move |e| e.target() == node_id && e.is_active())
141    }
142
143    /// Iterate every active edge.
144    pub fn active_edges(&self) -> impl Iterator<Item = &E> {
145        self.edges.iter().filter(|e| e.is_active())
146    }
147
148    /// Active-edge directed adjacency list: `source -> [target]`.
149    /// Sub-graphs that need a different view (e.g. undirected
150    /// neighbours) build their own from `active_edges`.
151    pub fn adjacency_list(&self) -> HashMap<E::NodeId, Vec<E::NodeId>> {
152        let mut adj_list: HashMap<E::NodeId, Vec<E::NodeId>> = HashMap::new();
153        for edge in self.active_edges() {
154            adj_list
155                .entry(edge.source())
156                .or_default()
157                .push(edge.target());
158        }
159        adj_list
160    }
161
162    /// Active-edge count.
163    pub fn active_edge_count(&self) -> usize {
164        self.edges.iter().filter(|e| e.is_active()).count()
165    }
166
167    /// Would adding `source -> target` create a cycle in the active
168    /// directed adjacency?
169    pub fn would_create_cycle(&self, source: E::NodeId, target: E::NodeId) -> bool {
170        let adj_list = self.adjacency_list();
171        algorithms::would_create_cycle(&adj_list, source, target)
172    }
173
174    /// Does the active directed adjacency contain any cycle?
175    pub fn has_cycle(&self) -> bool {
176        let adj_list = self.adjacency_list();
177        algorithms::has_cycle(&adj_list)
178    }
179
180    /// Set of nodes reachable from `start` via the active directed
181    /// adjacency.
182    pub fn reachable_from(&self, start: E::NodeId) -> std::collections::HashSet<E::NodeId> {
183        let adj_list = self.adjacency_list();
184        algorithms::reachable_from(&adj_list, start)
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::graph::edge::EdgeBase;
192    use uuid::Uuid;
193
194    fn base(source: Uuid, target: Uuid) -> EdgeBase {
195        EdgeBase::new(source, target)
196    }
197
198    #[test]
199    fn test_new_returns_empty_edge_store() {
200        let graph: EdgeStore<EdgeBase> = EdgeStore::new();
201        assert_eq!(graph.edge_count(), 0);
202    }
203
204    #[test]
205    fn test_add_edge_increments_count() {
206        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
207        let source = Uuid::new_v4();
208        let target = Uuid::new_v4();
209
210        graph.add_edge(base(source, target));
211        assert_eq!(graph.edge_count(), 1);
212    }
213
214    #[test]
215    fn test_remove_directed_edge_only_matches_exact_orientation() {
216        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
217        let a = Uuid::new_v4();
218        let b = Uuid::new_v4();
219
220        graph.add_edge(base(a, b));
221        assert!(!graph.remove_directed_edge(b, a), "wrong orientation");
222        assert_eq!(graph.edge_count(), 1);
223        assert!(graph.remove_directed_edge(a, b));
224        assert_eq!(graph.edge_count(), 0);
225    }
226
227    #[test]
228    fn test_remove_undirected_edge_matches_either_orientation() {
229        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
230        let a = Uuid::new_v4();
231        let b = Uuid::new_v4();
232
233        graph.add_edge(base(a, b));
234        assert!(
235            graph.remove_undirected_edge(b, a),
236            "symmetric on either ordering"
237        );
238        assert_eq!(graph.edge_count(), 0);
239    }
240
241    #[test]
242    fn test_remove_directed_edge_preserves_archived_record() {
243        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
244        let a = Uuid::new_v4();
245        let b = Uuid::new_v4();
246
247        let mut archived = base(a, b);
248        archived.archive();
249        graph.add_edge(archived);
250        graph.add_edge(base(a, b)); // active
251
252        assert!(graph.remove_directed_edge(a, b));
253        assert_eq!(
254            graph.edge_count(),
255            1,
256            "archived record must survive the active remove"
257        );
258        assert_eq!(graph.active_edge_count(), 0);
259    }
260
261    #[test]
262    fn test_remove_directed_edge_returns_false_when_only_archived_exists() {
263        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
264        let a = Uuid::new_v4();
265        let b = Uuid::new_v4();
266        let mut archived = base(a, b);
267        archived.archive();
268        graph.add_edge(archived);
269
270        assert!(
271            !graph.remove_directed_edge(a, b),
272            "no active edge means remove is a no-op"
273        );
274        assert_eq!(graph.edge_count(), 1, "archived record untouched");
275    }
276
277    #[test]
278    fn test_remove_undirected_edge_preserves_archived_record() {
279        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
280        let a = Uuid::new_v4();
281        let b = Uuid::new_v4();
282
283        let mut archived = base(a, b);
284        archived.archive();
285        graph.add_edge(archived);
286        graph.add_edge(base(b, a)); // active in opposite ordering
287
288        assert!(graph.remove_undirected_edge(a, b));
289        assert_eq!(graph.edge_count(), 1);
290        assert_eq!(graph.active_edge_count(), 0);
291    }
292
293    #[test]
294    fn test_remove_node_drops_every_incident_edge() {
295        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
296        let node_a = Uuid::new_v4();
297        let node_b = Uuid::new_v4();
298        let node_c = Uuid::new_v4();
299
300        graph.add_edge(base(node_a, node_b));
301        graph.add_edge(base(node_b, node_c));
302        graph.add_edge(base(node_c, node_a));
303
304        graph.remove_node(node_b);
305        assert_eq!(graph.edge_count(), 1);
306    }
307
308    #[test]
309    fn test_archive_node_hides_incident_edges_from_active_count() {
310        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
311        let node_a = Uuid::new_v4();
312        let node_b = Uuid::new_v4();
313        let node_c = Uuid::new_v4();
314
315        graph.add_edge(base(node_a, node_b));
316        graph.add_edge(base(node_b, node_c));
317
318        assert_eq!(graph.active_edge_count(), 2);
319
320        graph.archive_node(node_b);
321        assert_eq!(graph.edge_count(), 2);
322        assert_eq!(graph.active_edge_count(), 0);
323    }
324
325    #[test]
326    fn test_unarchive_node_restores_active_count() {
327        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
328        let node_a = Uuid::new_v4();
329        let node_b = Uuid::new_v4();
330
331        graph.add_edge(base(node_a, node_b));
332        graph.archive_node(node_a);
333        assert_eq!(graph.active_edge_count(), 0);
334
335        graph.unarchive_node(node_a);
336        assert_eq!(graph.active_edge_count(), 1);
337    }
338
339    #[test]
340    fn test_outgoing_and_incoming_partition_by_endpoint_role() {
341        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
342        let node_a = Uuid::new_v4();
343        let node_b = Uuid::new_v4();
344        let node_c = Uuid::new_v4();
345
346        graph.add_edge(base(node_a, node_b));
347        graph.add_edge(base(node_a, node_c));
348        graph.add_edge(base(node_c, node_a));
349
350        assert_eq!(graph.outgoing(node_a).count(), 2);
351        assert_eq!(graph.incoming(node_a).count(), 1);
352        assert_eq!(graph.outgoing(node_b).count(), 0);
353        assert_eq!(graph.incoming(node_b).count(), 1);
354    }
355
356    #[test]
357    fn test_would_create_cycle_detects_directed_path_closing() {
358        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
359        let node_a = Uuid::new_v4();
360        let node_b = Uuid::new_v4();
361        let node_c = Uuid::new_v4();
362
363        graph.add_edge(base(node_a, node_b));
364        graph.add_edge(base(node_b, node_c));
365
366        assert!(graph.would_create_cycle(node_c, node_a));
367        assert!(graph.would_create_cycle(node_c, node_b));
368    }
369
370    #[test]
371    fn test_adjacency_list_counts_active_outgoing_per_node() {
372        let mut graph: EdgeStore<EdgeBase> = EdgeStore::new();
373        let node_a = Uuid::new_v4();
374        let node_b = Uuid::new_v4();
375        let node_c = Uuid::new_v4();
376
377        graph.add_edge(base(node_a, node_b));
378        graph.add_edge(base(node_b, node_c));
379
380        let adj_list = graph.adjacency_list();
381        assert_eq!(adj_list.len(), 2);
382        assert_eq!(adj_list.get(&node_a).unwrap().len(), 1);
383        assert_eq!(adj_list.get(&node_b).unwrap().len(), 1);
384    }
385}