Skip to main content

mnem_core/objects/
edge.rs

1//! The [`Edge`] object.
2//!
3//! Per SPEC §4.2:
4//!
5//! ```text
6//! Edge: {
7//!   _kind: "edge",
8//!   id:    EdgeId (16 bytes),
9//!   etype: string,
10//!   src:   NodeId (16 bytes),
11//!   dst:   NodeId (16 bytes),
12//!   props: map<string, Ipld>,
13//! }
14//! ```
15//!
16//! Edges reference their endpoints by **stable `NodeId`**, never by content
17//! hash (SPEC §4.2, ): a node property edit does not invalidate
18//! edges referencing it.
19
20use std::collections::BTreeMap;
21
22use ipld_core::ipld::Ipld;
23use serde::{Deserialize, Deserializer, Serialize, Serializer};
24
25use crate::id::{EdgeId, NodeId};
26
27/// A typed, directed link between two Nodes.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Edge {
30    /// Stable edge identity. Survives property edits.
31    pub id: EdgeId,
32    /// Free-form edge-type label (`"knows"`, `"cites"`, …).
33    pub etype: String,
34    /// Source `NodeId`.
35    pub src: NodeId,
36    /// Destination `NodeId`.
37    pub dst: NodeId,
38    /// Edge properties. Values are any DAG-CBOR value.
39    pub props: BTreeMap<String, Ipld>,
40    /// Forward-compat extension map per SPEC §3.2.
41    pub extra: BTreeMap<String, Ipld>,
42}
43
44impl Edge {
45    /// The `_kind` discriminator for edges. `"edge"` on the wire.
46    pub const KIND: &'static str = "edge";
47
48    /// Construct an edge with no properties.
49    #[must_use]
50    pub fn new(id: EdgeId, etype: impl Into<String>, src: NodeId, dst: NodeId) -> Self {
51        Self {
52            id,
53            etype: etype.into(),
54            src,
55            dst,
56            props: BTreeMap::new(),
57            extra: BTreeMap::new(),
58        }
59    }
60
61    /// Attach a property. Returns `self` for chaining.
62    #[must_use]
63    pub fn with_prop(mut self, key: impl Into<String>, value: impl Into<Ipld>) -> Self {
64        self.props.insert(key.into(), value.into());
65        self
66    }
67}
68
69// ---------------- Edge serde ----------------
70
71#[derive(Serialize, Deserialize)]
72struct EdgeWire {
73    #[serde(rename = "_kind")]
74    kind: String,
75    id: EdgeId,
76    etype: String,
77    src: NodeId,
78    dst: NodeId,
79    props: BTreeMap<String, Ipld>,
80    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
81    extra: BTreeMap<String, Ipld>,
82}
83
84impl Serialize for Edge {
85    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
86        EdgeWire {
87            kind: Self::KIND.into(),
88            id: self.id,
89            etype: self.etype.clone(),
90            src: self.src,
91            dst: self.dst,
92            props: self.props.clone(),
93            extra: self.extra.clone(),
94        }
95        .serialize(serializer)
96    }
97}
98
99impl<'de> Deserialize<'de> for Edge {
100    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101        let wire = EdgeWire::deserialize(deserializer)?;
102        if wire.kind != Self::KIND {
103            return Err(serde::de::Error::custom(format!(
104                "expected _kind='{}', got '{}'",
105                Self::KIND,
106                wire.kind
107            )));
108        }
109        Ok(Self {
110            id: wire.id,
111            etype: wire.etype,
112            src: wire.src,
113            dst: wire.dst,
114            props: wire.props,
115            extra: wire.extra,
116        })
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use crate::codec::{from_canonical_bytes, to_canonical_bytes};
124
125    fn sample() -> Edge {
126        Edge::new(
127            EdgeId::from_bytes_raw([3u8; 16]),
128            "knows",
129            NodeId::from_bytes_raw([1u8; 16]),
130            NodeId::from_bytes_raw([2u8; 16]),
131        )
132        .with_prop("since", Ipld::Integer(2020))
133    }
134
135    #[test]
136    fn edge_round_trip_byte_identity() {
137        let original = sample();
138        let bytes = to_canonical_bytes(&original).expect("encode");
139        let decoded: Edge = from_canonical_bytes(&bytes).expect("decode");
140        assert_eq!(original, decoded);
141        let bytes2 = to_canonical_bytes(&decoded).expect("re-encode");
142        assert_eq!(bytes, bytes2);
143    }
144
145    #[test]
146    fn edge_kind_rejection() {
147        let wire = EdgeWire {
148            kind: "node".into(),
149            id: EdgeId::from_bytes_raw([4u8; 16]),
150            etype: "x".into(),
151            src: NodeId::from_bytes_raw([1u8; 16]),
152            dst: NodeId::from_bytes_raw([2u8; 16]),
153            props: BTreeMap::new(),
154            extra: BTreeMap::new(),
155        };
156        let bytes = serde_ipld_dagcbor::to_vec(&wire).expect("encode");
157        let err = serde_ipld_dagcbor::from_slice::<Edge>(&bytes).unwrap_err();
158        assert!(err.to_string().contains("_kind"));
159    }
160}