Skip to main content

weavatrix_graph/
graph.rs

1use crate::{Edge, GraphError, Node, NodeId, Result, SourceSpan};
2use serde::{Deserialize, Deserializer, Serialize, de::Error as _};
3use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
6pub struct Graph {
7    nodes: Vec<Node>,
8    edges: Vec<Edge>,
9}
10
11impl Graph {
12    /// Creates a validated graph and canonicalizes its ordering.
13    ///
14    /// # Errors
15    ///
16    /// Returns an error for conflicting nodes, dangling edges, empty extractor
17    /// identities, or invalid source spans.
18    pub fn try_from_parts(
19        nodes: impl IntoIterator<Item = Node>,
20        edges: impl IntoIterator<Item = Edge>,
21    ) -> Result<Self> {
22        let mut builder = GraphBuilder::new();
23        for node in nodes {
24            builder.add_node(node)?;
25        }
26        for edge in edges {
27            builder.add_edge(edge)?;
28        }
29        builder.build()
30    }
31
32    #[must_use]
33    pub fn nodes(&self) -> &[Node] {
34        &self.nodes
35    }
36
37    #[must_use]
38    pub fn edges(&self) -> &[Edge] {
39        &self.edges
40    }
41
42    #[must_use]
43    pub fn node(&self, id: &str) -> Option<&Node> {
44        self.nodes
45            .binary_search_by(|node| node.id.as_str().cmp(id))
46            .ok()
47            .map(|index| &self.nodes[index])
48    }
49
50    pub fn outgoing<'graph>(
51        &'graph self,
52        id: &'graph NodeId,
53    ) -> impl Iterator<Item = &'graph Edge> {
54        self.edges.iter().filter(move |edge| &edge.source == id)
55    }
56
57    pub fn incoming<'graph>(
58        &'graph self,
59        id: &'graph NodeId,
60    ) -> impl Iterator<Item = &'graph Edge> {
61        self.edges.iter().filter(move |edge| &edge.target == id)
62    }
63
64    #[must_use]
65    pub const fn node_count(&self) -> usize {
66        self.nodes.len()
67    }
68
69    #[must_use]
70    pub const fn edge_count(&self) -> usize {
71        self.edges.len()
72    }
73
74    #[must_use]
75    pub const fn is_empty(&self) -> bool {
76        self.nodes.is_empty() && self.edges.is_empty()
77    }
78
79    #[must_use]
80    pub fn into_parts(self) -> (Vec<Node>, Vec<Edge>) {
81        (self.nodes, self.edges)
82    }
83}
84
85#[derive(Deserialize)]
86struct GraphWire {
87    nodes: Vec<Node>,
88    edges: Vec<Edge>,
89}
90
91impl<'de> Deserialize<'de> for Graph {
92    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
93    where
94        D: Deserializer<'de>,
95    {
96        let wire = GraphWire::deserialize(deserializer)?;
97        Self::try_from_parts(wire.nodes, wire.edges).map_err(D::Error::custom)
98    }
99}
100
101#[derive(Debug, Default)]
102pub struct GraphBuilder {
103    nodes: BTreeMap<NodeId, Node>,
104    edges: BTreeSet<Edge>,
105}
106
107impl GraphBuilder {
108    #[must_use]
109    pub const fn new() -> Self {
110        Self {
111            nodes: BTreeMap::new(),
112            edges: BTreeSet::new(),
113        }
114    }
115
116    /// Adds a node idempotently.
117    ///
118    /// # Errors
119    ///
120    /// Returns an error when the same identifier already has a different
121    /// definition or the node contains an invalid source span.
122    pub fn add_node(&mut self, node: Node) -> Result<&mut Self> {
123        if let Some(span) = &node.span {
124            validate_span(span)?;
125        }
126        if let Some(language) = &node.language {
127            validate_language(language)?;
128        }
129        if let Some(existing) = self.nodes.get(&node.id) {
130            if existing == &node {
131                return Ok(self);
132            }
133            return Err(GraphError::ConflictingNode {
134                id: node.id.to_string(),
135            });
136        }
137        self.nodes.insert(node.id.clone(), node);
138        Ok(self)
139    }
140
141    /// Adds an edge idempotently. Endpoint existence is validated by `build`,
142    /// so callers may insert edges before nodes.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error when provenance or its source span is invalid.
147    pub fn add_edge(&mut self, edge: Edge) -> Result<&mut Self> {
148        if edge.provenance.extractor.is_empty() {
149            return Err(GraphError::EmptyExtractor);
150        }
151        if let Some(span) = &edge.provenance.span {
152            validate_span(span)?;
153        }
154        self.edges.insert(edge);
155        Ok(self)
156    }
157
158    /// Validates all endpoints and returns an immutable graph.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error when an edge references a missing source or target.
163    pub fn build(self) -> Result<Graph> {
164        for edge in &self.edges {
165            if !self.nodes.contains_key(&edge.source) {
166                return Err(GraphError::MissingEdgeSource {
167                    id: edge.source.to_string(),
168                });
169            }
170            if !self.nodes.contains_key(&edge.target) {
171                return Err(GraphError::MissingEdgeTarget {
172                    id: edge.target.to_string(),
173                });
174            }
175        }
176        Ok(Graph {
177            nodes: self.nodes.into_values().collect(),
178            edges: self.edges.into_iter().collect(),
179        })
180    }
181}
182
183fn validate_language(language: &str) -> Result<()> {
184    if language.is_empty() || language.trim() != language {
185        return Err(GraphError::InvalidKind {
186            category: "language",
187            value: language.to_owned(),
188        });
189    }
190    Ok(())
191}
192
193fn validate_span(span: &SourceSpan) -> Result<()> {
194    if span.file.is_empty() {
195        return Err(GraphError::InvalidSpan {
196            file: span.file.clone(),
197            reason: "file must not be empty",
198        });
199    }
200    if span.start.line == 0 || span.start.column == 0 || span.end.line == 0 || span.end.column == 0
201    {
202        return Err(GraphError::InvalidSpan {
203            file: span.file.clone(),
204            reason: "positions are one-based",
205        });
206    }
207    if span.end < span.start {
208        return Err(GraphError::InvalidSpan {
209            file: span.file.clone(),
210            reason: "end precedes start",
211        });
212    }
213    Ok(())
214}