adrscope/domain/
graph.rs

1//! Graph data structures for ADR relationships.
2//!
3//! This module provides types for representing ADR relationships as a graph,
4//! enabling network visualization of related decisions.
5
6use serde::Serialize;
7
8use super::{Adr, Status};
9
10/// A node in the ADR relationship graph.
11#[derive(Debug, Clone, Serialize)]
12pub struct Node {
13    /// The ADR identifier.
14    pub id: String,
15    /// The ADR status for coloring.
16    pub status: String,
17    /// The ADR title for display.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub title: Option<String>,
20}
21
22impl Node {
23    /// Creates a new node from an ADR.
24    #[must_use]
25    pub fn from_adr(adr: &Adr) -> Self {
26        Self {
27            id: adr.id().as_str().to_string(),
28            status: adr.status().as_str().to_string(),
29            title: Some(adr.title().to_string()),
30        }
31    }
32
33    /// Creates a new node with just an ID (for referenced but non-existent ADRs).
34    #[must_use]
35    pub fn placeholder(id: impl Into<String>) -> Self {
36        Self {
37            id: id.into(),
38            status: Status::default().as_str().to_string(),
39            title: None,
40        }
41    }
42}
43
44/// The type of relationship between two ADRs.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
46#[serde(rename_all = "lowercase")]
47pub enum EdgeType {
48    /// A general relationship (from `related` field).
49    Related,
50    /// One ADR supersedes another.
51    Supersedes,
52}
53
54impl EdgeType {
55    /// Returns the edge type as a string.
56    #[must_use]
57    pub const fn as_str(&self) -> &'static str {
58        match self {
59            Self::Related => "related",
60            Self::Supersedes => "supersedes",
61        }
62    }
63}
64
65/// An edge connecting two ADRs in the graph.
66#[derive(Debug, Clone, Serialize)]
67pub struct Edge {
68    /// Source ADR identifier.
69    pub source: String,
70    /// Target ADR identifier.
71    pub target: String,
72    /// Type of relationship.
73    #[serde(rename = "type")]
74    pub edge_type: EdgeType,
75}
76
77impl Edge {
78    /// Creates a new edge.
79    #[must_use]
80    pub fn new(source: impl Into<String>, target: impl Into<String>, edge_type: EdgeType) -> Self {
81        Self {
82            source: source.into(),
83            target: target.into(),
84            edge_type,
85        }
86    }
87
88    /// Creates a "related" edge.
89    #[must_use]
90    pub fn related(source: impl Into<String>, target: impl Into<String>) -> Self {
91        Self::new(source, target, EdgeType::Related)
92    }
93
94    /// Creates a "supersedes" edge.
95    #[must_use]
96    pub fn supersedes(source: impl Into<String>, target: impl Into<String>) -> Self {
97        Self::new(source, target, EdgeType::Supersedes)
98    }
99}
100
101/// The complete ADR relationship graph.
102#[derive(Debug, Clone, Serialize)]
103pub struct Graph {
104    /// All nodes (ADRs) in the graph.
105    pub nodes: Vec<Node>,
106    /// All edges (relationships) between ADRs.
107    pub edges: Vec<Edge>,
108}
109
110impl Graph {
111    /// Creates a new empty graph.
112    #[must_use]
113    pub fn new() -> Self {
114        Self {
115            nodes: Vec::new(),
116            edges: Vec::new(),
117        }
118    }
119
120    /// Builds a graph from a collection of ADRs.
121    #[must_use]
122    pub fn from_adrs(adrs: &[Adr]) -> Self {
123        let mut nodes: Vec<Node> = adrs.iter().map(Node::from_adr).collect();
124        let mut edges: Vec<Edge> = Vec::new();
125
126        // Build a set of known ADR IDs for resolving references
127        let known_ids: std::collections::HashSet<&str> =
128            adrs.iter().map(|a| a.id().as_str()).collect();
129
130        // Process relationships
131        for adr in adrs {
132            let source_id = adr.id().as_str();
133
134            // Handle `related` references
135            for related_ref in adr.related() {
136                // Extract ID from filename reference (e.g., "adr_0005.md" -> "adr_0005")
137                let target_id = extract_id_from_ref(related_ref);
138
139                // Add edge
140                edges.push(Edge::related(source_id, &target_id));
141
142                // If target doesn't exist in our collection, add a placeholder node
143                if !known_ids.contains(target_id.as_str()) {
144                    nodes.push(Node::placeholder(&target_id));
145                }
146            }
147        }
148
149        // Remove duplicate nodes (placeholders for ADRs we later found)
150        nodes.dedup_by(|a, b| a.id == b.id);
151
152        Self { nodes, edges }
153    }
154
155    /// Returns the number of nodes in the graph.
156    #[must_use]
157    pub fn node_count(&self) -> usize {
158        self.nodes.len()
159    }
160
161    /// Returns the number of edges in the graph.
162    #[must_use]
163    pub fn edge_count(&self) -> usize {
164        self.edges.len()
165    }
166
167    /// Checks if the graph is empty (no nodes).
168    #[must_use]
169    pub fn is_empty(&self) -> bool {
170        self.nodes.is_empty()
171    }
172}
173
174impl Default for Graph {
175    fn default() -> Self {
176        Self::new()
177    }
178}
179
180/// Extracts an ADR ID from a reference string.
181///
182/// Handles formats like "adr_0005.md" or just "adr_0005".
183fn extract_id_from_ref(reference: &str) -> String {
184    reference
185        .strip_suffix(".md")
186        .unwrap_or(reference)
187        .to_string()
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::domain::{AdrId, Frontmatter};
194    use std::path::PathBuf;
195
196    fn create_test_adr(id: &str, related: Vec<String>) -> Adr {
197        let frontmatter = Frontmatter::new(format!("Test {id}")).with_related(related);
198        Adr::new(
199            AdrId::new(id),
200            format!("{id}.md"),
201            PathBuf::from(format!("{id}.md")),
202            frontmatter,
203            String::new(),
204            String::new(),
205            String::new(),
206        )
207    }
208
209    #[test]
210    fn test_node_from_adr() {
211        let adr = create_test_adr("adr_0001", vec![]);
212        let node = Node::from_adr(&adr);
213
214        assert_eq!(node.id, "adr_0001");
215        assert_eq!(node.status, "proposed");
216        assert_eq!(node.title, Some("Test adr_0001".to_string()));
217    }
218
219    #[test]
220    fn test_node_placeholder() {
221        let node = Node::placeholder("adr_0005");
222        assert_eq!(node.id, "adr_0005");
223        assert!(node.title.is_none());
224    }
225
226    #[test]
227    fn test_edge_creation() {
228        let edge = Edge::related("adr_0001", "adr_0005");
229        assert_eq!(edge.source, "adr_0001");
230        assert_eq!(edge.target, "adr_0005");
231        assert_eq!(edge.edge_type, EdgeType::Related);
232    }
233
234    #[test]
235    fn test_graph_from_adrs() {
236        let adrs = vec![
237            create_test_adr("adr_0001", vec!["adr_0002.md".to_string()]),
238            create_test_adr("adr_0002", vec![]),
239        ];
240
241        let graph = Graph::from_adrs(&adrs);
242
243        assert_eq!(graph.node_count(), 2);
244        assert_eq!(graph.edge_count(), 1);
245        assert_eq!(graph.edges[0].source, "adr_0001");
246        assert_eq!(graph.edges[0].target, "adr_0002");
247    }
248
249    #[test]
250    fn test_graph_with_missing_reference() {
251        let adrs = vec![create_test_adr(
252            "adr_0001",
253            vec!["adr_missing.md".to_string()],
254        )];
255
256        let graph = Graph::from_adrs(&adrs);
257
258        // Should have 2 nodes: the actual ADR and a placeholder for the missing one
259        assert_eq!(graph.node_count(), 2);
260        assert!(graph.nodes.iter().any(|n| n.id == "adr_missing"));
261    }
262
263    #[test]
264    fn test_extract_id_from_ref() {
265        assert_eq!(extract_id_from_ref("adr_0005.md"), "adr_0005");
266        assert_eq!(extract_id_from_ref("adr_0005"), "adr_0005");
267    }
268
269    #[test]
270    fn test_edge_type_as_str() {
271        assert_eq!(EdgeType::Related.as_str(), "related");
272        assert_eq!(EdgeType::Supersedes.as_str(), "supersedes");
273    }
274
275    #[test]
276    fn test_edge_supersedes() {
277        let edge = Edge::supersedes("adr_0002", "adr_0001");
278        assert_eq!(edge.source, "adr_0002");
279        assert_eq!(edge.target, "adr_0001");
280        assert_eq!(edge.edge_type, EdgeType::Supersedes);
281    }
282
283    #[test]
284    fn test_graph_new() {
285        let graph = Graph::new();
286        assert!(graph.is_empty());
287        assert_eq!(graph.node_count(), 0);
288        assert_eq!(graph.edge_count(), 0);
289    }
290
291    #[test]
292    fn test_graph_default() {
293        let graph = Graph::default();
294        assert!(graph.is_empty());
295        assert_eq!(graph.nodes.len(), 0);
296        assert_eq!(graph.edges.len(), 0);
297    }
298
299    #[test]
300    fn test_graph_is_empty() {
301        let empty_graph = Graph::new();
302        assert!(empty_graph.is_empty());
303
304        let non_empty_graph = Graph::from_adrs(&[create_test_adr("adr_0001", vec![])]);
305        assert!(!non_empty_graph.is_empty());
306    }
307
308    #[test]
309    fn test_edge_new() {
310        let edge = Edge::new("source", "target", EdgeType::Related);
311        assert_eq!(edge.source, "source");
312        assert_eq!(edge.target, "target");
313        assert_eq!(edge.edge_type, EdgeType::Related);
314    }
315
316    #[test]
317    fn test_node_serialization() {
318        let node = Node::placeholder("adr_0001");
319        let json = serde_json::to_string(&node).expect("should serialize");
320        assert!(json.contains("\"id\":\"adr_0001\""));
321        // title should be skipped when None
322        assert!(!json.contains("\"title\":null"));
323    }
324
325    #[test]
326    fn test_edge_serialization() {
327        let edge = Edge::supersedes("adr_0002", "adr_0001");
328        let json = serde_json::to_string(&edge).expect("should serialize");
329        assert!(json.contains("\"source\":\"adr_0002\""));
330        assert!(json.contains("\"target\":\"adr_0001\""));
331        assert!(json.contains("\"type\":\"supersedes\""));
332    }
333
334    #[test]
335    fn test_graph_serialization() {
336        let adrs = vec![
337            create_test_adr("adr_0001", vec!["adr_0002.md".to_string()]),
338            create_test_adr("adr_0002", vec![]),
339        ];
340        let graph = Graph::from_adrs(&adrs);
341        let json = serde_json::to_string(&graph).expect("should serialize");
342        assert!(json.contains("\"nodes\""));
343        assert!(json.contains("\"edges\""));
344    }
345}