Skip to main content

nodedb_types/id/
edge.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Graph edge identifier.
4
5use std::fmt;
6use std::str::FromStr;
7
8use serde::{Deserialize, Serialize};
9
10use super::error::{IdError, validate};
11use super::node::NodeId;
12
13/// Identifies a graph edge. Returned by `graph_insert_edge`.
14///
15/// An edge is uniquely identified by `(src, dst, label, seq)`. The `seq`
16/// field disambiguates parallel edges — multiple edges that share the same
17/// `(src, dst, label)` bucket. `seq = 0` means "the first (or only) edge
18/// in this bucket". The engine-side allocator that assigns monotonically
19/// increasing `seq` values is a follow-up TODO; until it is wired in,
20/// callers use `seq = 0` via `EdgeId::try_first`.
21///
22/// # Wire format
23/// Serialization uses the **structured fields** (serde / zerompk).
24/// The `Display` / `FromStr` impl uses the **length-prefixed** text form
25/// `"{src_len}:{src}|{label_len}:{label}|{dst_len}:{dst}|{seq}"` — unambiguous
26/// even when node IDs or labels contain `|`, `:`, or `--` characters.
27/// The Display form is used only for logging and the pgwire DELETE path.
28#[derive(
29    Debug,
30    Clone,
31    PartialEq,
32    Eq,
33    Hash,
34    Serialize,
35    Deserialize,
36    zerompk::ToMessagePack,
37    zerompk::FromMessagePack,
38    rkyv::Archive,
39    rkyv::Serialize,
40    rkyv::Deserialize,
41)]
42pub struct EdgeId {
43    /// Source node identifier.
44    pub src: NodeId,
45    /// Destination node identifier.
46    pub dst: NodeId,
47    /// Edge label / relationship type (e.g. `"KNOWS"`, `"RELATES_TO"`).
48    pub label: String,
49    /// Sequence number within the `(src, dst, label)` bucket.
50    ///
51    /// `0` = "the only edge in this bucket (for now)".
52    /// Engine-side allocation of monotonically increasing `seq` values is a
53    /// TODO — see `nodedb/src/engine/graph/` for the follow-up location.
54    pub seq: u64,
55}
56
57impl EdgeId {
58    /// Create an `EdgeId` for the first (or only) edge in a `(src, dst, label)` bucket.
59    ///
60    /// Validates the label against the same rules as `NodeId::try_new`. Returns
61    /// `Err(IdError)` if the label is empty, too long, or contains a NUL byte.
62    /// Node IDs are accepted pre-validated (callers hold `NodeId` values which
63    /// were already validated at construction time).
64    pub fn try_first(src: NodeId, dst: NodeId, label: impl Into<String>) -> Result<Self, IdError> {
65        let label = label.into();
66        validate(&label)?;
67        Ok(Self {
68            src,
69            dst,
70            label,
71            seq: 0,
72        })
73    }
74
75    /// Create an `EdgeId` with an explicit sequence number.
76    ///
77    /// Validates the label against the same rules as `NodeId::try_new`. Returns
78    /// `Err(IdError)` if the label is empty, too long, or contains a NUL byte.
79    pub fn try_with_seq(
80        src: NodeId,
81        dst: NodeId,
82        label: impl Into<String>,
83        seq: u64,
84    ) -> Result<Self, IdError> {
85        let label = label.into();
86        validate(&label)?;
87        Ok(Self {
88            src,
89            dst,
90            label,
91            seq,
92        })
93    }
94}
95
96/// Error returned when parsing an `EdgeId` from its Display string.
97#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
98pub enum EdgeIdParseError {
99    #[error(
100        "missing segment: expected format '{{src_len}}:{{src}}|{{label_len}}:{{label}}|{{dst_len}}:{{dst}}|{{seq}}'"
101    )]
102    MissingSegment,
103    #[error("invalid length prefix in segment '{segment}': {reason}")]
104    InvalidLengthPrefix { segment: String, reason: String },
105    #[error("invalid seq value '{value}': {reason}")]
106    InvalidSeq { value: String, reason: String },
107    /// The label embedded in the wire string failed ID validation.
108    #[error("invalid label: {0}")]
109    InvalidLabel(IdError),
110}
111
112impl fmt::Display for EdgeId {
113    /// Length-prefixed format: `"{src_len}:{src}|{label_len}:{label}|{dst_len}:{dst}|{seq}"`.
114    ///
115    /// Unambiguous regardless of what characters appear in node IDs or labels.
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        let src = self.src.as_str();
118        let label = &self.label;
119        let dst = self.dst.as_str();
120        write!(
121            f,
122            "{}:{}|{}:{}|{}:{}|{}",
123            src.len(),
124            src,
125            label.len(),
126            label,
127            dst.len(),
128            dst,
129            self.seq
130        )
131    }
132}
133
134impl FromStr for EdgeId {
135    type Err = EdgeIdParseError;
136
137    fn from_str(s: &str) -> Result<Self, Self::Err> {
138        // Parse one `"{len}:{value}"` segment from the front of `input`.
139        // Returns `(value, rest_after_separator)` where the separator is `|`.
140        fn take_segment(input: &str, separator: char) -> Result<(&str, &str), EdgeIdParseError> {
141            let colon = input.find(':').ok_or(EdgeIdParseError::MissingSegment)?;
142            let len_str = &input[..colon];
143            let len: usize =
144                len_str
145                    .parse()
146                    .map_err(|e| EdgeIdParseError::InvalidLengthPrefix {
147                        segment: len_str.to_owned(),
148                        reason: format!("{e}"),
149                    })?;
150            let after_colon = &input[colon + 1..];
151            if after_colon.len() < len {
152                return Err(EdgeIdParseError::MissingSegment);
153            }
154            let value = &after_colon[..len];
155            let rest = &after_colon[len..];
156            // Consume the expected separator (or allow empty for the last segment).
157            let rest = if rest.starts_with(separator) {
158                &rest[1..]
159            } else if rest.is_empty() {
160                rest
161            } else {
162                return Err(EdgeIdParseError::MissingSegment);
163            };
164            Ok((value, rest))
165        }
166
167        let (src, rest) = take_segment(s, '|')?;
168        let (label, rest) = take_segment(rest, '|')?;
169        let (dst, rest) = take_segment(rest, '|')?;
170
171        // Remaining `rest` is the seq value.
172        let seq: u64 = rest.parse().map_err(|e| EdgeIdParseError::InvalidSeq {
173            value: rest.to_owned(),
174            reason: format!("{e}"),
175        })?;
176
177        // Validate the label parsed from the wire string.
178        validate(label).map_err(EdgeIdParseError::InvalidLabel)?;
179
180        Ok(EdgeId {
181            // src and dst come from previously-validated wire bytes (NodeDB server output).
182            src: NodeId::from_validated(src.to_owned()),
183            dst: NodeId::from_validated(dst.to_owned()),
184            label: label.to_owned(),
185            seq,
186        })
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::super::error::ID_MAX_LEN;
193    use super::*;
194
195    #[test]
196    fn try_first_accepts_valid() {
197        let src = NodeId::try_new("alice").expect("valid");
198        let dst = NodeId::try_new("bob").expect("valid");
199        let e = EdgeId::try_first(src, dst, "KNOWS").expect("valid label");
200        assert_eq!(e.seq, 0);
201        assert_eq!(e.label, "KNOWS");
202    }
203
204    #[test]
205    fn edge_id_label_validation_propagates_empty() {
206        let src = NodeId::try_new("x").expect("valid");
207        let dst = NodeId::try_new("y").expect("valid");
208        assert_eq!(EdgeId::try_first(src, dst, ""), Err(IdError::Empty));
209    }
210
211    #[test]
212    fn edge_id_label_validation_propagates_too_long() {
213        let src = NodeId::try_new("x").expect("valid");
214        let dst = NodeId::try_new("y").expect("valid");
215        let long = "L".repeat(ID_MAX_LEN + 1);
216        assert!(matches!(
217            EdgeId::try_first(src, dst, long),
218            Err(IdError::TooLong { .. })
219        ));
220    }
221
222    #[test]
223    fn edge_id_label_validation_propagates_nul() {
224        let src = NodeId::try_new("x").expect("valid");
225        let dst = NodeId::try_new("y").expect("valid");
226        assert_eq!(
227            EdgeId::try_first(src, dst, "la\0bel"),
228            Err(IdError::ContainsNul)
229        );
230    }
231
232    #[test]
233    fn edge_id_no_collision_with_dashes_in_label() {
234        let a = EdgeId::try_first(
235            NodeId::try_new("alice").expect("v"),
236            NodeId::try_new("bob").expect("v"),
237            "--",
238        )
239        .expect("valid");
240        let b = EdgeId::try_first(
241            NodeId::try_new("alice").expect("v"),
242            NodeId::try_new("bob").expect("v"),
243            "-->",
244        )
245        .expect("valid");
246        assert_ne!(
247            a, b,
248            "labels '--' and '-->' must not produce the same EdgeId"
249        );
250    }
251
252    #[test]
253    fn edge_id_parallel_edges_distinguished_by_seq() {
254        let a = EdgeId::try_first(
255            NodeId::try_new("x").expect("v"),
256            NodeId::try_new("y").expect("v"),
257            "KNOWS",
258        )
259        .expect("valid");
260        let b = EdgeId::try_with_seq(
261            NodeId::try_new("x").expect("v"),
262            NodeId::try_new("y").expect("v"),
263            "KNOWS",
264            1,
265        )
266        .expect("valid");
267        assert_ne!(a, b, "different seq values must produce distinct EdgeIds");
268        assert_eq!(a.seq, 0);
269        assert_eq!(b.seq, 1);
270    }
271
272    #[test]
273    fn edge_id_display_fromstr_roundtrip() {
274        let e = EdgeId::try_first(
275            NodeId::try_new("alice").expect("v"),
276            NodeId::try_new("bob").expect("v"),
277            "KNOWS",
278        )
279        .expect("valid");
280        let s = e.to_string();
281        let parsed: EdgeId = s.parse().expect("round-trip must succeed");
282        assert_eq!(e, parsed);
283
284        let e2 = EdgeId::try_with_seq(
285            NodeId::try_new("a|b:c").expect("v"),
286            NodeId::try_new("d-->e").expect("v"),
287            "label--weird-->one",
288            42,
289        )
290        .expect("valid");
291        let s2 = e2.to_string();
292        let parsed2: EdgeId = s2
293            .parse()
294            .expect("round-trip with weird chars must succeed");
295        assert_eq!(e2, parsed2);
296
297        let e3 = EdgeId::try_first(
298            NodeId::try_new("n1").expect("v"),
299            NodeId::try_new("n2").expect("v"),
300            "REL",
301        )
302        .expect("valid");
303        let s3 = e3.to_string();
304        let parsed3: EdgeId = s3.parse().expect("seq=0 round-trip must succeed");
305        assert_eq!(e3, parsed3);
306    }
307
308    #[test]
309    fn edge_id_serde_roundtrip() {
310        let e = EdgeId::try_with_seq(
311            NodeId::try_new("src").expect("v"),
312            NodeId::try_new("dst").expect("v"),
313            "EDGE",
314            7,
315        )
316        .expect("valid");
317        let bytes = zerompk::to_msgpack_vec(&e).expect("msgpack serialization must succeed");
318        let decoded: EdgeId =
319            zerompk::from_msgpack(&bytes).expect("msgpack deserialization must succeed");
320        assert_eq!(e, decoded);
321    }
322
323    #[test]
324    fn from_str_rejects_empty_label() {
325        // An EdgeId whose Display has an empty label segment should fail FromStr
326        // because validate("") returns IdError::Empty.
327        // We construct one using from_validated (bypassing validation) and
328        // then parse the Display string — expecting InvalidLabel(Empty).
329        let e = EdgeId {
330            src: NodeId::from_validated("a".to_owned()),
331            dst: NodeId::from_validated("b".to_owned()),
332            label: String::new(),
333            seq: 0,
334        };
335        let s = e.to_string();
336        let err = s
337            .parse::<EdgeId>()
338            .expect_err("empty label must fail parse");
339        assert!(matches!(
340            err,
341            EdgeIdParseError::InvalidLabel(IdError::Empty)
342        ));
343    }
344}