Skip to main content

grust_core/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashMap},
3    fmt,
4};
5
6use async_trait::async_trait;
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
8
9pub type Result<T> = std::result::Result<T, GrustError>;
10pub type Props = BTreeMap<String, Value>;
11
12#[derive(Debug, thiserror::Error)]
13pub enum GrustError {
14    #[error("backend error: {0}")]
15    Backend(String),
16    #[error("schema error: {0}")]
17    Schema(String),
18    #[error("unsupported graph feature: {0}")]
19    Unsupported(String),
20    #[error("serialization error: {0}")]
21    Serialization(String),
22}
23
24macro_rules! string_newtype {
25    ($(#[$meta:meta])* $name:ident) => {
26        $(#[$meta])*
27        #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
28        pub struct $name(String);
29
30        impl $name {
31            pub fn new(value: impl Into<String>) -> Self {
32                Self(value.into())
33            }
34
35            pub fn as_str(&self) -> &str {
36                &self.0
37            }
38
39            pub fn into_string(self) -> String {
40                self.0
41            }
42        }
43
44        impl fmt::Display for $name {
45            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46                f.write_str(&self.0)
47            }
48        }
49
50        impl From<String> for $name {
51            fn from(value: String) -> Self {
52                Self::new(value)
53            }
54        }
55
56        impl From<&str> for $name {
57            fn from(value: &str) -> Self {
58                Self::new(value)
59            }
60        }
61
62        impl From<&String> for $name {
63            fn from(value: &String) -> Self {
64                Self::new(value.clone())
65            }
66        }
67
68        impl From<&$name> for $name {
69            fn from(value: &$name) -> Self {
70                value.clone()
71            }
72        }
73    };
74}
75
76string_newtype!(
77    /// Stable application-level node identifier.
78    NodeId
79);
80string_newtype!(
81    /// Optional application-level edge identifier.
82    EdgeId
83);
84string_newtype!(
85    /// Node or edge label.
86    Label
87);
88
89/// Validated RFC 3339 date-time string used by [`Value::DateTime`].
90#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
91pub struct RfcDate(String);
92
93impl RfcDate {
94    /// Parses and stores an RFC 3339 date-time such as
95    /// `2026-06-12T09:30:00Z` or `2026-06-12T09:30:00.123+02:00`.
96    pub fn parse(value: impl Into<String>) -> Result<Self> {
97        let value = value.into();
98        if is_rfc3339_datetime(&value) {
99            Ok(Self(value))
100        } else {
101            Err(GrustError::Schema(format!(
102                "'{value}' is not an RFC 3339 date-time"
103            )))
104        }
105    }
106
107    pub fn as_str(&self) -> &str {
108        &self.0
109    }
110
111    pub fn into_string(self) -> String {
112        self.0
113    }
114}
115
116impl fmt::Display for RfcDate {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        f.write_str(&self.0)
119    }
120}
121
122impl Serialize for RfcDate {
123    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
124    where
125        S: Serializer,
126    {
127        serializer.serialize_str(self.as_str())
128    }
129}
130
131impl<'de> Deserialize<'de> for RfcDate {
132    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
133    where
134        D: Deserializer<'de>,
135    {
136        let value = String::deserialize(deserializer)?;
137        Self::parse(value).map_err(de::Error::custom)
138    }
139}
140
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[serde(tag = "type", content = "value", rename_all = "snake_case")]
143pub enum Value {
144    Null,
145    Bool(bool),
146    Int(i64),
147    Float(f64),
148    String(String),
149    /// An RFC 3339 date-time, e.g. `2026-06-12T09:30:00Z`. Construct with
150    /// [`Value::datetime`] to get format validation.
151    DateTime(RfcDate),
152    StringArray(Vec<String>),
153    IntArray(Vec<i64>),
154    FloatArray(Vec<f64>),
155    Json(serde_json::Value),
156}
157
158impl Value {
159    /// Creates a `Value::DateTime`, validating that the input is an RFC 3339
160    /// date-time such as `2026-06-12T09:30:00Z` or
161    /// `2026-06-12T09:30:00.123+02:00`.
162    pub fn datetime(value: impl Into<String>) -> Result<Self> {
163        RfcDate::parse(value).map(Self::DateTime)
164    }
165
166    pub fn as_str(&self) -> Option<&str> {
167        match self {
168            Self::String(value) => Some(value),
169            _ => None,
170        }
171    }
172
173    pub fn as_datetime(&self) -> Option<&str> {
174        match self {
175            Self::DateTime(value) => Some(value.as_str()),
176            _ => None,
177        }
178    }
179
180    pub fn as_string_array(&self) -> Option<&[String]> {
181        match self {
182            Self::StringArray(values) => Some(values),
183            _ => None,
184        }
185    }
186
187    pub fn as_int_array(&self) -> Option<&[i64]> {
188        match self {
189            Self::IntArray(values) => Some(values),
190            _ => None,
191        }
192    }
193
194    pub fn as_float_array(&self) -> Option<&[f64]> {
195        match self {
196            Self::FloatArray(values) => Some(values),
197            _ => None,
198        }
199    }
200
201    /// Converts to a plain (untagged) JSON value. `DateTime` becomes a JSON
202    /// string, so a `to_json`/`from_json` round trip yields `Value::String`.
203    pub fn to_json(&self) -> serde_json::Value {
204        match self {
205            Self::Null => serde_json::Value::Null,
206            Self::Bool(value) => serde_json::Value::Bool(*value),
207            Self::Int(value) => serde_json::Value::from(*value),
208            Self::Float(value) => serde_json::Value::from(*value),
209            Self::String(value) => serde_json::Value::String(value.clone()),
210            Self::DateTime(value) => serde_json::Value::String(value.as_str().to_string()),
211            Self::StringArray(values) => serde_json::Value::from(values.clone()),
212            Self::IntArray(values) => serde_json::Value::from(values.clone()),
213            Self::FloatArray(values) => serde_json::Value::from(values.clone()),
214            Self::Json(value) => value.clone(),
215        }
216    }
217
218    /// Converts from JSON, accepting both plain values and the tagged
219    /// `{"type": ..., "value": ...}` form that `Value`'s serde representation
220    /// produces.
221    pub fn from_json(value: serde_json::Value) -> Self {
222        if let serde_json::Value::Object(mapping) = &value
223            && mapping.contains_key("type")
224            && mapping.contains_key("value")
225            && let Ok(tagged) = serde_json::from_value(value.clone())
226        {
227            return tagged;
228        }
229        Self::from(value)
230    }
231}
232
233/// Validates the RFC 3339 date-time shape `YYYY-MM-DDTHH:MM:SS[.frac](Z|±HH:MM)`.
234fn is_rfc3339_datetime(value: &str) -> bool {
235    let bytes = value.as_bytes();
236    if bytes.len() < 20 {
237        return false;
238    }
239    let digit = |i: usize| bytes[i].is_ascii_digit();
240    let all_digits = |range: std::ops::Range<usize>| range.clone().all(digit);
241    let pair = |i: usize| (bytes[i] - b'0') * 10 + (bytes[i + 1] - b'0');
242
243    if !(all_digits(0..4)
244        && bytes[4] == b'-'
245        && all_digits(5..7)
246        && bytes[7] == b'-'
247        && all_digits(8..10)
248        && bytes[10] == b'T'
249        && all_digits(11..13)
250        && bytes[13] == b':'
251        && all_digits(14..16)
252        && bytes[16] == b':'
253        && all_digits(17..19))
254    {
255        return false;
256    }
257    let year = bytes[0..4]
258        .iter()
259        .fold(0u16, |acc, digit| acc * 10 + u16::from(digit - b'0'));
260    let month = pair(5);
261    let day = pair(8);
262    let leap_year = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
263    let max_day = match month {
264        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
265        4 | 6 | 9 | 11 => 30,
266        2 if leap_year => 29,
267        2 => 28,
268        _ => return false,
269    };
270    if !(day >= 1 && day <= max_day && pair(11) < 24 && pair(14) < 60 && pair(17) <= 60) {
271        return false;
272    }
273
274    let mut i = 19;
275    if bytes[i] == b'.' {
276        let start = i + 1;
277        i = start;
278        while i < bytes.len() && bytes[i].is_ascii_digit() {
279            i += 1;
280        }
281        if i == start {
282            return false;
283        }
284    }
285    match bytes.get(i) {
286        Some(b'Z') => i + 1 == bytes.len(),
287        Some(b'+' | b'-') => {
288            i + 6 == bytes.len()
289                && all_digits(i + 1..i + 3)
290                && bytes[i + 3] == b':'
291                && all_digits(i + 4..i + 6)
292                && pair(i + 1) < 24
293                && pair(i + 4) < 60
294        }
295        _ => false,
296    }
297}
298
299impl From<String> for Value {
300    fn from(value: String) -> Self {
301        Self::String(value)
302    }
303}
304
305impl From<&str> for Value {
306    fn from(value: &str) -> Self {
307        Self::String(value.to_string())
308    }
309}
310
311impl From<&String> for Value {
312    fn from(value: &String) -> Self {
313        Self::String(value.clone())
314    }
315}
316
317impl From<Vec<String>> for Value {
318    fn from(value: Vec<String>) -> Self {
319        Self::StringArray(value)
320    }
321}
322
323impl From<Vec<i64>> for Value {
324    fn from(value: Vec<i64>) -> Self {
325        Self::IntArray(value)
326    }
327}
328
329impl From<Vec<f64>> for Value {
330    fn from(value: Vec<f64>) -> Self {
331        Self::FloatArray(value)
332    }
333}
334
335impl From<bool> for Value {
336    fn from(value: bool) -> Self {
337        Self::Bool(value)
338    }
339}
340
341impl From<i64> for Value {
342    fn from(value: i64) -> Self {
343        Self::Int(value)
344    }
345}
346
347impl From<i32> for Value {
348    fn from(value: i32) -> Self {
349        Self::Int(i64::from(value))
350    }
351}
352
353impl From<usize> for Value {
354    fn from(value: usize) -> Self {
355        Self::Int(value as i64)
356    }
357}
358
359impl From<f64> for Value {
360    fn from(value: f64) -> Self {
361        Self::Float(value)
362    }
363}
364
365impl From<serde_json::Value> for Value {
366    fn from(value: serde_json::Value) -> Self {
367        match value {
368            serde_json::Value::Null => Self::Null,
369            serde_json::Value::Bool(value) => Self::Bool(value),
370            serde_json::Value::Number(value) => {
371                if let Some(value) = value.as_i64() {
372                    Self::Int(value)
373                } else if let Some(value) = value.as_f64() {
374                    Self::Float(value)
375                } else {
376                    Self::Json(serde_json::Value::Number(value))
377                }
378            }
379            serde_json::Value::String(value) => Self::String(value),
380            serde_json::Value::Array(values) => {
381                let ints = values
382                    .iter()
383                    .filter_map(serde_json::Value::as_i64)
384                    .collect::<Vec<_>>();
385                if !values.is_empty() && ints.len() == values.len() {
386                    return Self::IntArray(ints);
387                }
388                let floats = values
389                    .iter()
390                    .filter_map(serde_json::Value::as_f64)
391                    .collect::<Vec<_>>();
392                if !values.is_empty() && floats.len() == values.len() {
393                    return Self::FloatArray(floats);
394                }
395                let strings = values
396                    .iter()
397                    .filter_map(|value| value.as_str().map(ToString::to_string))
398                    .collect::<Vec<_>>();
399                if strings.len() == values.len() {
400                    Self::StringArray(strings)
401                } else {
402                    Self::Json(serde_json::Value::Array(values))
403                }
404            }
405            serde_json::Value::Object(value) => Self::Json(serde_json::Value::Object(value)),
406        }
407    }
408}
409
410#[cfg(feature = "typed-garde")]
411pub mod typed {
412    use serde::{Serialize, de::DeserializeOwned};
413
414    use crate::{
415        Edge, EdgeId, Graph, GraphBuilder, GrustError, Node, NodeId, Props, PutOutcome, Result,
416        Value,
417    };
418
419    pub use garde;
420    #[cfg(feature = "typed-zod-rs")]
421    pub use zod_rs;
422
423    pub trait TypedNode: garde::Validate + Serialize {
424        const LABEL: &'static str;
425
426        fn node_id(&self) -> NodeId;
427
428        fn node_props(&self) -> Result<Props> {
429            props_from_serialize(self)
430        }
431
432        fn from_node(node: &Node) -> Result<Self>
433        where
434            Self: Sized + DeserializeOwned,
435            Self::Context: Default,
436        {
437            let ctx = Self::Context::default();
438            Self::from_node_with(node, &ctx)
439        }
440
441        fn from_node_with(node: &Node, ctx: &Self::Context) -> Result<Self>
442        where
443            Self: Sized + DeserializeOwned,
444        {
445            if node.label.as_str() != Self::LABEL {
446                return Err(GrustError::Schema(format!(
447                    "node '{}' has label '{}', expected '{}'",
448                    node.id.as_str(),
449                    node.label.as_str(),
450                    Self::LABEL
451                )));
452            }
453            let typed: Self =
454                serde_json::from_value(props_to_plain_json(&node.props)).map_err(|err| {
455                    GrustError::Serialization(format!("typed node decode error: {err}"))
456                })?;
457            typed
458                .validate_with(ctx)
459                .map_err(|err| validation_error(Self::LABEL, err))?;
460            let typed_id = typed.node_id();
461            if typed_id != node.id {
462                return Err(GrustError::Schema(format!(
463                    "typed node '{}' decoded id '{}', expected '{}'",
464                    Self::LABEL,
465                    typed_id.as_str(),
466                    node.id.as_str()
467                )));
468            }
469            Ok(typed)
470        }
471    }
472
473    pub trait TypedEdge: garde::Validate + Serialize {
474        const LABEL: &'static str;
475
476        fn source_node_id(&self) -> NodeId;
477
478        fn target_node_id(&self) -> NodeId;
479
480        fn edge_id(&self) -> Option<EdgeId> {
481            None
482        }
483
484        fn edge_props(&self) -> Result<Props> {
485            props_from_serialize(self)
486        }
487
488        fn from_edge(edge: &Edge) -> Result<Self>
489        where
490            Self: Sized + DeserializeOwned,
491            Self::Context: Default,
492        {
493            let ctx = Self::Context::default();
494            Self::from_edge_with(edge, &ctx)
495        }
496
497        fn from_edge_with(edge: &Edge, ctx: &Self::Context) -> Result<Self>
498        where
499            Self: Sized + DeserializeOwned,
500        {
501            if edge.label.as_str() != Self::LABEL {
502                return Err(GrustError::Schema(format!(
503                    "edge from '{}' to '{}' has label '{}', expected '{}'",
504                    edge.from.as_str(),
505                    edge.to.as_str(),
506                    edge.label.as_str(),
507                    Self::LABEL
508                )));
509            }
510            let typed: Self =
511                serde_json::from_value(props_to_plain_json(&edge.props)).map_err(|err| {
512                    GrustError::Serialization(format!("typed edge decode error: {err}"))
513                })?;
514            typed
515                .validate_with(ctx)
516                .map_err(|err| validation_error(Self::LABEL, err))?;
517            if typed.source_node_id() != edge.from || typed.target_node_id() != edge.to {
518                return Err(GrustError::Schema(format!(
519                    "typed edge '{}' decoded endpoints '{}' -> '{}', expected '{}' -> '{}'",
520                    Self::LABEL,
521                    typed.source_node_id().as_str(),
522                    typed.target_node_id().as_str(),
523                    edge.from.as_str(),
524                    edge.to.as_str()
525                )));
526            }
527            if let Some(decoded_id) = typed.edge_id()
528                && edge
529                    .id
530                    .as_ref()
531                    .is_some_and(|edge_id| edge_id != &decoded_id)
532            {
533                return Err(GrustError::Schema(format!(
534                    "typed edge '{}' decoded id '{}', expected '{}'",
535                    Self::LABEL,
536                    decoded_id.as_str(),
537                    edge.id.as_ref().expect("edge id checked").as_str()
538                )));
539            }
540            Ok(typed)
541        }
542    }
543
544    #[derive(Clone, Debug, Default)]
545    pub struct TypedGraphBuilder {
546        builder: GraphBuilder,
547    }
548
549    impl TypedGraphBuilder {
550        pub fn new() -> Self {
551            Self::default()
552        }
553
554        pub fn from_builder(builder: GraphBuilder) -> Self {
555            Self { builder }
556        }
557
558        pub fn from_graph(graph: Graph) -> Self {
559            let mut builder = GraphBuilder::new();
560            for node in graph.nodes {
561                builder.add_node(node);
562            }
563            for edge in graph.edges {
564                builder.add_edge(edge);
565            }
566            Self { builder }
567        }
568
569        pub fn add_raw_node(&mut self, node: Node) -> NodeId {
570            self.builder.add_node(node)
571        }
572
573        pub fn add_raw_edge(&mut self, edge: Edge) -> PutOutcome {
574            self.builder.add_edge(edge)
575        }
576
577        pub fn add_node<T>(&mut self, node: &T) -> Result<NodeId>
578        where
579            T: TypedNode,
580            T::Context: Default,
581        {
582            node.validate()
583                .map_err(|err| validation_error(T::LABEL, err))?;
584            self.add_validated_node(node)
585        }
586
587        pub fn add_node_with<T>(&mut self, node: &T, ctx: &T::Context) -> Result<NodeId>
588        where
589            T: TypedNode,
590        {
591            node.validate_with(ctx)
592                .map_err(|err| validation_error(T::LABEL, err))?;
593            self.add_validated_node(node)
594        }
595
596        pub fn add_edge<T>(&mut self, edge: &T) -> Result<PutOutcome>
597        where
598            T: TypedEdge,
599            T::Context: Default,
600        {
601            edge.validate()
602                .map_err(|err| validation_error(T::LABEL, err))?;
603            self.add_validated_edge(edge)
604        }
605
606        pub fn add_edge_with<T>(&mut self, edge: &T, ctx: &T::Context) -> Result<PutOutcome>
607        where
608            T: TypedEdge,
609        {
610            edge.validate_with(ctx)
611                .map_err(|err| validation_error(T::LABEL, err))?;
612            self.add_validated_edge(edge)
613        }
614
615        #[cfg(feature = "typed-zod-rs")]
616        pub fn add_node_from_json<T, S>(
617            &mut self,
618            schema: &S,
619            value: &serde_json::Value,
620        ) -> Result<NodeId>
621        where
622            T: TypedNode + DeserializeOwned,
623            T::Context: Default,
624            S: zod_rs::Schema<serde_json::Value>,
625        {
626            let node = parse_typed_json::<T, S>(schema, value)?;
627            self.add_validated_node(&node)
628        }
629
630        #[cfg(feature = "typed-zod-rs")]
631        pub fn add_node_from_json_with<T, S>(
632            &mut self,
633            schema: &S,
634            value: &serde_json::Value,
635            ctx: &T::Context,
636        ) -> Result<NodeId>
637        where
638            T: TypedNode + DeserializeOwned,
639            S: zod_rs::Schema<serde_json::Value>,
640        {
641            let node = parse_typed_json_with::<T, S>(schema, value, ctx)?;
642            self.add_validated_node(&node)
643        }
644
645        #[cfg(feature = "typed-zod-rs")]
646        pub fn add_edge_from_json<T, S>(
647            &mut self,
648            schema: &S,
649            value: &serde_json::Value,
650        ) -> Result<PutOutcome>
651        where
652            T: TypedEdge + DeserializeOwned,
653            T::Context: Default,
654            S: zod_rs::Schema<serde_json::Value>,
655        {
656            let edge = parse_typed_json::<T, S>(schema, value)?;
657            self.add_validated_edge(&edge)
658        }
659
660        #[cfg(feature = "typed-zod-rs")]
661        pub fn add_edge_from_json_with<T, S>(
662            &mut self,
663            schema: &S,
664            value: &serde_json::Value,
665            ctx: &T::Context,
666        ) -> Result<PutOutcome>
667        where
668            T: TypedEdge + DeserializeOwned,
669            S: zod_rs::Schema<serde_json::Value>,
670        {
671            let edge = parse_typed_json_with::<T, S>(schema, value, ctx)?;
672            self.add_validated_edge(&edge)
673        }
674
675        #[must_use = "discarding this means the typed graph was not built"]
676        pub fn build(self) -> Graph {
677            self.builder.build()
678        }
679
680        pub fn into_builder(self) -> GraphBuilder {
681            self.builder
682        }
683
684        fn add_validated_node<T>(&mut self, node: &T) -> Result<NodeId>
685        where
686            T: TypedNode,
687        {
688            let node_id = node.node_id();
689            let mut props = node.node_props()?;
690            props
691                .entry("id".to_string())
692                .or_insert_with(|| Value::from(node_id.as_str()));
693            let graph_node = Node::new(T::LABEL, node_id, props);
694            Ok(self.builder.add_node(graph_node))
695        }
696
697        fn add_validated_edge<T>(&mut self, edge: &T) -> Result<PutOutcome>
698        where
699            T: TypedEdge,
700        {
701            let mut graph_edge = Edge::new(
702                T::LABEL,
703                edge.source_node_id(),
704                edge.target_node_id(),
705                edge.edge_props()?,
706            );
707            graph_edge.id = edge.edge_id();
708            Ok(self.builder.add_edge(graph_edge))
709        }
710    }
711
712    pub fn props_from_serialize<T>(value: &T) -> Result<Props>
713    where
714        T: Serialize + ?Sized,
715    {
716        let serialized = serde_json::to_value(value)
717            .map_err(|err| GrustError::Serialization(format!("typed props error: {err}")))?;
718        let serde_json::Value::Object(fields) = serialized else {
719            return Err(GrustError::Schema(
720                "typed graph values must serialize as JSON objects".to_string(),
721            ));
722        };
723
724        Ok(fields
725            .into_iter()
726            .map(|(key, value)| (key, Value::from(value)))
727            .collect())
728    }
729
730    fn props_to_plain_json(props: &Props) -> serde_json::Value {
731        serde_json::Value::Object(
732            props
733                .iter()
734                .map(|(key, value)| (key.clone(), value.to_json()))
735                .collect(),
736        )
737    }
738
739    #[cfg(feature = "typed-zod-rs")]
740    pub fn parse_typed_json<T, S>(schema: &S, value: &serde_json::Value) -> Result<T>
741    where
742        T: DeserializeOwned + garde::Validate,
743        T::Context: Default,
744        S: zod_rs::Schema<serde_json::Value>,
745    {
746        let ctx = T::Context::default();
747        parse_typed_json_with(schema, value, &ctx)
748    }
749
750    #[cfg(feature = "typed-zod-rs")]
751    pub fn parse_typed_json_with<T, S>(
752        schema: &S,
753        value: &serde_json::Value,
754        ctx: &T::Context,
755    ) -> Result<T>
756    where
757        T: DeserializeOwned + garde::Validate,
758        S: zod_rs::Schema<serde_json::Value>,
759    {
760        schema
761            .safe_parse(value)
762            .map_err(|err| GrustError::Schema(format!("zod-rs validation failed: {err}")))?;
763        let typed: T = serde_json::from_value(value.clone())
764            .map_err(|err| GrustError::Serialization(format!("typed JSON decode error: {err}")))?;
765        typed
766            .validate_with(ctx)
767            .map_err(|err| GrustError::Schema(format!("typed validation failed: {err}")))?;
768        Ok(typed)
769    }
770
771    fn validation_error(label: &str, err: garde::Report) -> GrustError {
772        GrustError::Schema(format!("{label} validation failed: {err}"))
773    }
774}
775
776#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
777pub struct Node {
778    pub id: NodeId,
779    pub label: Label,
780    pub props: Props,
781}
782
783impl Node {
784    pub fn new(label: impl Into<Label>, id: impl Into<NodeId>, props: impl Into<Props>) -> Self {
785        let id = id.into();
786        let mut props = props.into();
787        props
788            .entry("id".to_string())
789            .or_insert_with(|| Value::from(id.as_str()));
790        Self {
791            id,
792            label: label.into(),
793            props,
794        }
795    }
796}
797
798#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
799pub struct Edge {
800    pub id: Option<EdgeId>,
801    pub from: NodeId,
802    pub to: NodeId,
803    pub label: Label,
804    pub props: Props,
805}
806
807impl Edge {
808    pub fn new(
809        label: impl Into<Label>,
810        from: impl Into<NodeId>,
811        to: impl Into<NodeId>,
812        props: impl Into<Props>,
813    ) -> Self {
814        Self {
815            id: None,
816            from: from.into(),
817            to: to.into(),
818            label: label.into(),
819            props: props.into(),
820        }
821    }
822
823    pub fn with_id(mut self, id: impl Into<EdgeId>) -> Self {
824        self.id = Some(id.into());
825        self
826    }
827}
828
829/// Normalizes an edge label into an uppercase backend relationship type.
830///
831/// Non-ASCII-alphanumeric characters become underscores. Empty labels fall
832/// back to `RELATED_TO`.
833pub fn relationship_type(value: &str) -> String {
834    let relationship = value
835        .chars()
836        .map(|ch| {
837            if ch.is_ascii_alphanumeric() {
838                ch.to_ascii_uppercase()
839            } else {
840                '_'
841            }
842        })
843        .collect::<String>();
844    if relationship.is_empty() {
845        "RELATED_TO".to_string()
846    } else {
847        relationship
848    }
849}
850
851/// Normalizes arbitrary schema text into a lower_snake_case backend identifier.
852///
853/// This helper is for SQL-like backends that require identifiers to start with a
854/// non-digit ASCII alphanumeric or underscore character after normalization.
855pub fn schema_identifier(value: &str) -> Result<String> {
856    let identifier = value
857        .chars()
858        .map(|ch| {
859            if ch.is_ascii_alphanumeric() {
860                ch.to_ascii_lowercase()
861            } else {
862                '_'
863            }
864        })
865        .collect::<String>();
866    if identifier.is_empty()
867        || identifier
868            .chars()
869            .next()
870            .is_some_and(|ch| ch.is_ascii_digit())
871    {
872        return Err(GrustError::Schema(format!(
873            "invalid schema identifier '{value}'"
874        )));
875    }
876    Ok(identifier)
877}
878
879/// Returns the stable key used by tabular/export backends for an edge.
880///
881/// Explicit edge IDs win. Otherwise the structural key joins `from`, `label`,
882/// and `to` with U+001F (Unit Separator). Callers that accept arbitrary IDs or
883/// labels should reject U+001F before relying on reversibility.
884pub fn edge_key(edge: &Edge) -> String {
885    edge.id
886        .as_ref()
887        .map(EdgeId::as_str)
888        .map(ToString::to_string)
889        .unwrap_or_else(|| {
890            let from = edge.from.as_str();
891            let label = edge.label.as_str();
892            let to = edge.to.as_str();
893            let mut key = String::with_capacity(from.len() + label.len() + to.len() + 2);
894            key.push_str(from);
895            key.push('\u{1f}');
896            key.push_str(label);
897            key.push('\u{1f}');
898            key.push_str(to);
899            key
900        })
901}
902
903#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
904pub struct Graph {
905    pub nodes: Vec<Node>,
906    pub edges: Vec<Edge>,
907}
908
909impl Graph {
910    pub fn new(nodes: Vec<Node>, edges: Vec<Edge>) -> Self {
911        Self { nodes, edges }
912    }
913
914    pub fn from_yaml(yaml: &str) -> Result<Self> {
915        yaml::graph_from_yaml(yaml)
916    }
917
918    pub fn to_yaml(&self) -> Result<String> {
919        yaml::graph_to_yaml(self)
920    }
921
922    pub fn from_json(json: &str) -> Result<Self> {
923        json::graph_from_json(json)
924    }
925
926    pub fn to_json(&self) -> Result<String> {
927        json::graph_to_json(self)
928    }
929
930    pub fn from_xml(xml: &str) -> Result<Self> {
931        xml::graph_from_xml(xml)
932    }
933
934    pub fn to_xml(&self) -> Result<String> {
935        xml::graph_to_xml(self)
936    }
937
938    pub fn builder() -> GraphBuilder {
939        GraphBuilder::new()
940    }
941}
942
943/// Dense, reusable indexes over a [`Graph`].
944///
945/// This is backend-neutral: it validates edge endpoints, maps node ids to
946/// stable vertex indexes, and stores edge adjacency by both node id and vertex
947/// index. Higher-level crates can use it for local analytics or query planning
948/// without rebuilding the same maps.
949#[derive(Clone, Debug, PartialEq)]
950pub struct GraphIndex {
951    vertex_by_id: HashMap<NodeId, usize>,
952    outgoing_by_vertex: Vec<Vec<usize>>,
953    incoming_by_vertex: Vec<Vec<usize>>,
954    edge_endpoints: Vec<(usize, usize)>,
955}
956
957impl GraphIndex {
958    pub fn new(graph: &Graph) -> Result<Self> {
959        let mut vertex_by_id = HashMap::with_capacity(graph.nodes.len());
960        for (index, vertex) in graph.nodes.iter().enumerate() {
961            if vertex_by_id.insert(vertex.id.clone(), index).is_some() {
962                return Err(GrustError::Schema(format!(
963                    "duplicate vertex id '{}'",
964                    vertex.id.as_str()
965                )));
966            }
967        }
968
969        let mut outgoing_by_vertex = vec![Vec::<usize>::new(); graph.nodes.len()];
970        let mut incoming_by_vertex = vec![Vec::<usize>::new(); graph.nodes.len()];
971        let mut edge_endpoints = Vec::<(usize, usize)>::with_capacity(graph.edges.len());
972
973        for (edge_index, edge) in graph.edges.iter().enumerate() {
974            let Some(&from_index) = vertex_by_id.get(&edge.from) else {
975                return Err(GrustError::Schema(format!(
976                    "edge source '{}' is not present in vertices",
977                    edge.from.as_str()
978                )));
979            };
980            let Some(&to_index) = vertex_by_id.get(&edge.to) else {
981                return Err(GrustError::Schema(format!(
982                    "edge destination '{}' is not present in vertices",
983                    edge.to.as_str()
984                )));
985            };
986
987            outgoing_by_vertex[from_index].push(edge_index);
988            incoming_by_vertex[to_index].push(edge_index);
989            edge_endpoints.push((from_index, to_index));
990        }
991
992        Ok(Self {
993            vertex_by_id,
994            outgoing_by_vertex,
995            incoming_by_vertex,
996            edge_endpoints,
997        })
998    }
999
1000    pub fn vertex_index(&self, id: &NodeId) -> Option<usize> {
1001        self.vertex_by_id.get(id).copied()
1002    }
1003
1004    pub fn require_vertex_index(&self, id: &NodeId) -> Result<usize> {
1005        self.vertex_index(id)
1006            .ok_or_else(|| GrustError::Schema(format!("vertex '{}' is not present", id.as_str())))
1007    }
1008
1009    pub fn outgoing_edges(&self, id: &NodeId) -> &[usize] {
1010        self.vertex_index(id)
1011            .map(|index| self.outgoing_by_vertex(index))
1012            .unwrap_or(&[])
1013    }
1014
1015    pub fn incoming_edges(&self, id: &NodeId) -> &[usize] {
1016        self.vertex_index(id)
1017            .map(|index| self.incoming_by_vertex(index))
1018            .unwrap_or(&[])
1019    }
1020
1021    pub fn outgoing_by_vertex(&self, index: usize) -> &[usize] {
1022        self.outgoing_by_vertex
1023            .get(index)
1024            .map(Vec::as_slice)
1025            .unwrap_or(&[])
1026    }
1027
1028    pub fn incoming_by_vertex(&self, index: usize) -> &[usize] {
1029        self.incoming_by_vertex
1030            .get(index)
1031            .map(Vec::as_slice)
1032            .unwrap_or(&[])
1033    }
1034
1035    pub fn edge_endpoints(&self, edge_index: usize) -> (usize, usize) {
1036        self.edge_endpoints[edge_index]
1037    }
1038
1039    pub fn edge_endpoints_slice(&self) -> &[(usize, usize)] {
1040        &self.edge_endpoints
1041    }
1042
1043    pub fn out_degree(&self, index: usize) -> usize {
1044        self.outgoing_by_vertex[index].len()
1045    }
1046
1047    pub fn in_degree(&self, index: usize) -> usize {
1048        self.incoming_by_vertex[index].len()
1049    }
1050
1051    pub fn degree(&self, index: usize) -> usize {
1052        self.in_degree(index) + self.out_degree(index)
1053    }
1054}
1055
1056mod graph_doc {
1057    use std::collections::{BTreeMap, BTreeSet};
1058
1059    use serde::{Deserialize, Serialize};
1060
1061    use crate::{Edge, EdgeId, Graph, GrustError, Label, Node, NodeId, Props, Value};
1062
1063    #[derive(Debug, Serialize, Deserialize)]
1064    pub(super) struct GraphDoc {
1065        #[serde(default)]
1066        pub(super) nodes: Vec<NodeDoc>,
1067        #[serde(default)]
1068        pub(super) edges: Vec<EdgeDoc>,
1069    }
1070
1071    #[derive(Debug, Serialize, Deserialize)]
1072    pub(super) struct NodeDoc {
1073        pub(super) id: NodeId,
1074        pub(super) label: Label,
1075        #[serde(default, deserialize_with = "deserialize_props")]
1076        pub(super) props: Props,
1077    }
1078
1079    #[derive(Debug, Serialize, Deserialize)]
1080    pub(super) struct NodeDocOut {
1081        pub(super) id: NodeId,
1082        pub(super) label: Label,
1083        #[serde(default)]
1084        pub(super) props: Props,
1085    }
1086
1087    #[derive(Debug, Serialize, Deserialize)]
1088    pub(super) struct EdgeDoc {
1089        #[serde(default)]
1090        pub(super) id: Option<EdgeId>,
1091        pub(super) label: Label,
1092        pub(super) from: NodeId,
1093        pub(super) to: NodeId,
1094        #[serde(default, deserialize_with = "deserialize_props")]
1095        pub(super) props: Props,
1096    }
1097
1098    #[derive(Debug, Serialize, Deserialize)]
1099    pub(super) struct EdgeDocOut {
1100        #[serde(default)]
1101        pub(super) id: Option<EdgeId>,
1102        pub(super) label: Label,
1103        pub(super) from: NodeId,
1104        pub(super) to: NodeId,
1105        #[serde(default)]
1106        pub(super) props: Props,
1107    }
1108
1109    pub(super) fn graph_from_doc(doc: GraphDoc) -> super::Result<Graph> {
1110        let mut ids = BTreeSet::new();
1111        for node in &doc.nodes {
1112            if !ids.insert(node.id.clone()) {
1113                return Err(GrustError::Schema(format!(
1114                    "duplicate node id '{}'",
1115                    node.id
1116                )));
1117            }
1118        }
1119
1120        let mut edges = Vec::with_capacity(doc.edges.len());
1121        for edge in doc.edges {
1122            if !ids.contains(&edge.from) {
1123                return Err(GrustError::Schema(format!(
1124                    "edge '{}' references unknown from node '{}'",
1125                    edge.label, edge.from
1126                )));
1127            }
1128            if !ids.contains(&edge.to) {
1129                return Err(GrustError::Schema(format!(
1130                    "edge '{}' references unknown to node '{}'",
1131                    edge.label, edge.to
1132                )));
1133            }
1134
1135            let mut graph_edge = Edge::new(edge.label, edge.from, edge.to, edge.props);
1136            graph_edge.id = edge.id;
1137            edges.push(graph_edge);
1138        }
1139
1140        let nodes = doc
1141            .nodes
1142            .into_iter()
1143            .map(|node| Node::new(node.label, node.id, node.props))
1144            .collect();
1145
1146        Ok(Graph::new(nodes, edges))
1147    }
1148
1149    pub(super) fn graph_to_doc(graph: &Graph) -> GraphDocOut {
1150        GraphDocOut {
1151            nodes: graph
1152                .nodes
1153                .iter()
1154                .map(|node| NodeDocOut {
1155                    id: node.id.clone(),
1156                    label: node.label.clone(),
1157                    props: without_generated_id(&node.props, &node.id),
1158                })
1159                .collect(),
1160            edges: graph
1161                .edges
1162                .iter()
1163                .map(|edge| EdgeDocOut {
1164                    id: edge.id.clone(),
1165                    label: edge.label.clone(),
1166                    from: edge.from.clone(),
1167                    to: edge.to.clone(),
1168                    props: edge.props.clone(),
1169                })
1170                .collect(),
1171        }
1172    }
1173
1174    fn without_generated_id(props: &Props, id: &NodeId) -> Props {
1175        let mut props = props.clone();
1176        if props.get("id") == Some(&Value::from(id.as_str())) {
1177            props.remove("id");
1178        }
1179        props
1180    }
1181
1182    fn deserialize_props<'de, D>(deserializer: D) -> std::result::Result<Props, D::Error>
1183    where
1184        D: serde::Deserializer<'de>,
1185    {
1186        let raw = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
1187        raw.into_iter()
1188            .map(|(key, value)| {
1189                value_from_json(value)
1190                    .map(|value| (key, value))
1191                    .map_err(serde::de::Error::custom)
1192            })
1193            .collect()
1194    }
1195
1196    fn value_from_json(value: serde_json::Value) -> std::result::Result<Value, String> {
1197        if let serde_json::Value::Object(mapping) = &value
1198            && mapping.contains_key("type")
1199            && mapping.contains_key("value")
1200        {
1201            return serde_json::from_value(value)
1202                .map_err(|err| format!("invalid tagged Grust value: {err}"));
1203        }
1204
1205        Ok(Value::from_json(value))
1206    }
1207
1208    #[derive(Debug, Serialize, Deserialize)]
1209    pub(super) struct GraphDocOut {
1210        pub(super) nodes: Vec<NodeDocOut>,
1211        pub(super) edges: Vec<EdgeDocOut>,
1212    }
1213}
1214
1215mod yaml {
1216    use crate::{Graph, GrustError};
1217
1218    pub(super) fn graph_from_yaml(yaml: &str) -> super::Result<Graph> {
1219        let doc: super::graph_doc::GraphDoc = serde_yaml::from_str(yaml)
1220            .map_err(|err| GrustError::Serialization(format!("YAML parse error: {err}")))?;
1221        super::graph_doc::graph_from_doc(doc)
1222    }
1223
1224    pub(super) fn graph_to_yaml(graph: &Graph) -> super::Result<String> {
1225        serde_yaml::to_string(&super::graph_doc::graph_to_doc(graph))
1226            .map_err(|err| GrustError::Serialization(format!("YAML serialization error: {err}")))
1227    }
1228}
1229
1230mod json {
1231    use crate::{Graph, GrustError};
1232
1233    pub(super) fn graph_from_json(json: &str) -> super::Result<Graph> {
1234        let doc: super::graph_doc::GraphDoc = serde_json::from_str(json)
1235            .map_err(|err| GrustError::Serialization(format!("JSON parse error: {err}")))?;
1236        super::graph_doc::graph_from_doc(doc)
1237    }
1238
1239    pub(super) fn graph_to_json(graph: &Graph) -> super::Result<String> {
1240        serde_json::to_string_pretty(&super::graph_doc::graph_to_doc(graph))
1241            .map_err(|err| GrustError::Serialization(format!("JSON serialization error: {err}")))
1242    }
1243}
1244
1245mod xml {
1246    use serde::{Deserialize, Serialize};
1247
1248    use crate::{Edge, EdgeId, Graph, GrustError, Label, Node, NodeId, Props, Value};
1249
1250    #[derive(Debug, Serialize, Deserialize)]
1251    #[serde(rename = "graph")]
1252    struct GraphXml {
1253        #[serde(default)]
1254        nodes: NodesXml,
1255        #[serde(default)]
1256        edges: EdgesXml,
1257    }
1258
1259    #[derive(Debug, Default, Serialize, Deserialize)]
1260    struct NodesXml {
1261        #[serde(rename = "node", default)]
1262        items: Vec<NodeXml>,
1263    }
1264
1265    #[derive(Debug, Default, Serialize, Deserialize)]
1266    struct EdgesXml {
1267        #[serde(rename = "edge", default)]
1268        items: Vec<EdgeXml>,
1269    }
1270
1271    #[derive(Debug, Serialize, Deserialize)]
1272    struct NodeXml {
1273        id: NodeId,
1274        label: Label,
1275        #[serde(default)]
1276        props: PropsXml,
1277    }
1278
1279    #[derive(Debug, Serialize, Deserialize)]
1280    struct EdgeXml {
1281        #[serde(default, skip_serializing_if = "Option::is_none")]
1282        id: Option<EdgeId>,
1283        label: Label,
1284        from: NodeId,
1285        to: NodeId,
1286        #[serde(default)]
1287        props: PropsXml,
1288    }
1289
1290    #[derive(Debug, Default, Serialize, Deserialize)]
1291    struct PropsXml {
1292        #[serde(rename = "prop", default)]
1293        items: Vec<PropXml>,
1294    }
1295
1296    #[derive(Debug, Serialize, Deserialize)]
1297    struct PropXml {
1298        key: String,
1299        value: Value,
1300    }
1301
1302    pub(super) fn graph_from_xml(xml: &str) -> super::Result<Graph> {
1303        let doc: GraphXml = quick_xml::de::from_str(xml)
1304            .map_err(|err| GrustError::Serialization(format!("XML parse error: {err}")))?;
1305        super::graph_doc::graph_from_doc(doc.into())
1306    }
1307
1308    pub(super) fn graph_to_xml(graph: &Graph) -> super::Result<String> {
1309        quick_xml::se::to_string(&GraphXml::from(graph))
1310            .map_err(|err| GrustError::Serialization(format!("XML serialization error: {err}")))
1311    }
1312
1313    impl From<GraphXml> for super::graph_doc::GraphDoc {
1314        fn from(value: GraphXml) -> Self {
1315            Self {
1316                nodes: value.nodes.items.into_iter().map(Into::into).collect(),
1317                edges: value.edges.items.into_iter().map(Into::into).collect(),
1318            }
1319        }
1320    }
1321
1322    impl From<NodeXml> for super::graph_doc::NodeDoc {
1323        fn from(value: NodeXml) -> Self {
1324            Self {
1325                id: value.id,
1326                label: value.label,
1327                props: value.props.into(),
1328            }
1329        }
1330    }
1331
1332    impl From<EdgeXml> for super::graph_doc::EdgeDoc {
1333        fn from(value: EdgeXml) -> Self {
1334            Self {
1335                id: value.id,
1336                label: value.label,
1337                from: value.from,
1338                to: value.to,
1339                props: value.props.into(),
1340            }
1341        }
1342    }
1343
1344    impl From<PropsXml> for Props {
1345        fn from(value: PropsXml) -> Self {
1346            value
1347                .items
1348                .into_iter()
1349                .map(|prop| (prop.key, prop.value))
1350                .collect()
1351        }
1352    }
1353
1354    impl From<&Graph> for GraphXml {
1355        fn from(graph: &Graph) -> Self {
1356            Self {
1357                nodes: NodesXml {
1358                    items: graph.nodes.iter().map(NodeXml::from).collect(),
1359                },
1360                edges: EdgesXml {
1361                    items: graph.edges.iter().map(EdgeXml::from).collect(),
1362                },
1363            }
1364        }
1365    }
1366
1367    impl From<&Node> for NodeXml {
1368        fn from(node: &Node) -> Self {
1369            let props = super::graph_doc::graph_to_doc(&Graph::new(vec![node.clone()], Vec::new()))
1370                .nodes
1371                .into_iter()
1372                .next()
1373                .expect("node exists")
1374                .props;
1375            Self {
1376                id: node.id.clone(),
1377                label: node.label.clone(),
1378                props: props.into(),
1379            }
1380        }
1381    }
1382
1383    impl From<&Edge> for EdgeXml {
1384        fn from(edge: &Edge) -> Self {
1385            Self {
1386                id: edge.id.clone(),
1387                label: edge.label.clone(),
1388                from: edge.from.clone(),
1389                to: edge.to.clone(),
1390                props: edge.props.clone().into(),
1391            }
1392        }
1393    }
1394
1395    impl From<Props> for PropsXml {
1396        fn from(value: Props) -> Self {
1397            Self {
1398                items: value
1399                    .into_iter()
1400                    .map(|(key, value)| PropXml { key, value })
1401                    .collect(),
1402            }
1403        }
1404    }
1405}
1406
1407#[derive(Clone, Debug, Default, Eq, PartialEq)]
1408pub enum EdgePolicy {
1409    AllowDuplicates,
1410    #[default]
1411    DedupeByFromLabelTo,
1412}
1413
1414#[derive(Clone, Debug, Default)]
1415pub struct GraphBuilder {
1416    nodes: BTreeMap<NodeId, Node>,
1417    edges: Vec<Edge>,
1418    edge_keys: BTreeSet<(NodeId, Label, NodeId)>,
1419    edge_policy: EdgePolicy,
1420}
1421
1422impl GraphBuilder {
1423    pub fn new() -> Self {
1424        Self::default()
1425    }
1426
1427    pub fn edge_policy(mut self, edge_policy: EdgePolicy) -> Self {
1428        self.edge_policy = edge_policy;
1429        self
1430    }
1431
1432    pub fn node<'a>(
1433        &'a mut self,
1434        label: impl Into<Label>,
1435        id: impl Into<NodeId>,
1436    ) -> NodeBuilder<'a> {
1437        NodeBuilder {
1438            builder: self,
1439            label: label.into(),
1440            id: id.into(),
1441            props: Props::new(),
1442        }
1443    }
1444
1445    pub fn edge<'a>(
1446        &'a mut self,
1447        label: impl Into<Label>,
1448        from: impl Into<NodeId>,
1449        to: impl Into<NodeId>,
1450    ) -> EdgeBuilder<'a> {
1451        EdgeBuilder {
1452            builder: self,
1453            id: None,
1454            label: label.into(),
1455            from: from.into(),
1456            to: to.into(),
1457            props: Props::new(),
1458        }
1459    }
1460
1461    /// Adds a node, merging with any existing node that has the same id.
1462    ///
1463    /// If a node with the same id and the same label already exists, the new
1464    /// props are merged in (new values win). If a node with the same id but a
1465    /// different label exists, the new node replaces it entirely (last write
1466    /// wins, matching `GraphStore::put_node` overwrite semantics).
1467    pub fn add_node(&mut self, node: Node) -> NodeId {
1468        let id = node.id.clone();
1469        self.nodes
1470            .entry(id.clone())
1471            .and_modify(|existing| {
1472                if existing.label == node.label {
1473                    existing.props.extend(node.props.clone());
1474                } else {
1475                    *existing = node.clone();
1476                }
1477            })
1478            .or_insert(node);
1479        id
1480    }
1481
1482    /// Adds an edge, reporting whether it was stored or dropped by the
1483    /// builder's [`EdgePolicy`].
1484    pub fn add_edge(&mut self, edge: Edge) -> PutOutcome {
1485        match self.edge_policy {
1486            EdgePolicy::AllowDuplicates => {
1487                self.edges.push(edge);
1488                PutOutcome::Inserted
1489            }
1490            EdgePolicy::DedupeByFromLabelTo => {
1491                let key = (edge.from.clone(), edge.label.clone(), edge.to.clone());
1492                if self.edge_keys.insert(key) {
1493                    self.edges.push(edge);
1494                    PutOutcome::Inserted
1495                } else {
1496                    PutOutcome::Deduped
1497                }
1498            }
1499        }
1500    }
1501
1502    #[must_use = "discarding this means the graph was not built"]
1503    pub fn build(self) -> Graph {
1504        Graph {
1505            nodes: self.nodes.into_values().collect(),
1506            edges: self.edges,
1507        }
1508    }
1509}
1510
1511pub struct NodeBuilder<'a> {
1512    builder: &'a mut GraphBuilder,
1513    label: Label,
1514    id: NodeId,
1515    props: Props,
1516}
1517
1518impl<'a> NodeBuilder<'a> {
1519    pub fn prop(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1520        self.props.insert(key.into(), value.into());
1521        self
1522    }
1523
1524    pub fn props(mut self, props: Props) -> Self {
1525        self.props.extend(props);
1526        self
1527    }
1528
1529    #[must_use = "discarding this means the node was not added to the builder"]
1530    pub fn finish(self) -> NodeId {
1531        let node = Node::new(self.label, self.id, self.props);
1532        self.builder.add_node(node)
1533    }
1534}
1535
1536pub struct EdgeBuilder<'a> {
1537    builder: &'a mut GraphBuilder,
1538    id: Option<EdgeId>,
1539    label: Label,
1540    from: NodeId,
1541    to: NodeId,
1542    props: Props,
1543}
1544
1545impl<'a> EdgeBuilder<'a> {
1546    pub fn id(mut self, id: impl Into<EdgeId>) -> Self {
1547        self.id = Some(id.into());
1548        self
1549    }
1550
1551    pub fn prop(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1552        self.props.insert(key.into(), value.into());
1553        self
1554    }
1555
1556    pub fn props(mut self, props: Props) -> Self {
1557        self.props.extend(props);
1558        self
1559    }
1560
1561    #[must_use = "discarding this means the edge was not added to the builder"]
1562    pub fn finish(self) -> PutOutcome {
1563        let mut edge = Edge::new(self.label, self.from, self.to, self.props);
1564        edge.id = self.id;
1565        self.builder.add_edge(edge)
1566    }
1567}
1568
1569#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1570pub struct GraphSchema {
1571    pub nodes: Vec<NodeType>,
1572    pub edges: Vec<EdgeType>,
1573}
1574
1575impl GraphSchema {
1576    pub fn builder() -> GraphSchemaBuilder {
1577        GraphSchemaBuilder::default()
1578    }
1579
1580    pub fn node_type(&self, label: &Label) -> Option<&NodeType> {
1581        self.nodes
1582            .iter()
1583            .find(|node_type| &node_type.label == label)
1584    }
1585
1586    pub fn edge_type(&self, label: &Label) -> Option<&EdgeType> {
1587        self.edges
1588            .iter()
1589            .find(|edge_type| &edge_type.label == label)
1590    }
1591
1592    pub fn validate_graph(&self, graph: &Graph) -> Result<()> {
1593        for node in &graph.nodes {
1594            self.validate_node(node)?;
1595        }
1596        let labels: BTreeMap<&NodeId, &Label> = graph
1597            .nodes
1598            .iter()
1599            .map(|node| (&node.id, &node.label))
1600            .collect();
1601        for edge in &graph.edges {
1602            self.validate_edge_with(edge, |id| labels.get(id).copied())?;
1603        }
1604        self.validate_edge_uniqueness(graph)
1605    }
1606
1607    /// Enforces each edge type's [`EdgeUniqueness`]: at most one edge of the
1608    /// type between a given endpoint pair (unordered for undirected types).
1609    fn validate_edge_uniqueness(&self, graph: &Graph) -> Result<()> {
1610        let mut seen = BTreeSet::new();
1611        for edge in &graph.edges {
1612            let Some(edge_type) = self.edge_type(&edge.label) else {
1613                continue;
1614            };
1615            if edge_type.uniqueness == EdgeUniqueness::None {
1616                continue;
1617            }
1618            let (a, b) = if edge_type.directed || edge.from <= edge.to {
1619                (&edge.from, &edge.to)
1620            } else {
1621                (&edge.to, &edge.from)
1622            };
1623            if !seen.insert((edge.label.clone(), a.clone(), b.clone())) {
1624                return Err(GrustError::Schema(format!(
1625                    "duplicate edge '{}' between '{}' and '{}' violates {:?} uniqueness",
1626                    edge.label.as_str(),
1627                    a.as_str(),
1628                    b.as_str(),
1629                    edge_type.uniqueness
1630                )));
1631            }
1632        }
1633        Ok(())
1634    }
1635
1636    pub fn validate_node(&self, node: &Node) -> Result<()> {
1637        let node_type = self.node_type(&node.label).ok_or_else(|| {
1638            GrustError::Schema(format!("schema has no node type '{}'", node.label.as_str()))
1639        })?;
1640        validate_props(
1641            &node.props,
1642            &node_type.fields,
1643            &format!("node '{}'", node.id.as_str()),
1644        )
1645    }
1646
1647    pub fn validate_edge(&self, edge: &Edge, graph: &Graph) -> Result<()> {
1648        self.validate_edge_with(edge, |id| {
1649            graph
1650                .nodes
1651                .iter()
1652                .find(|node| &node.id == id)
1653                .map(|node| &node.label)
1654        })
1655    }
1656
1657    /// Validates an edge using a label lookup instead of a full `Graph`, so
1658    /// stores can validate against their own node index without cloning.
1659    pub fn validate_edge_with<'a>(
1660        &self,
1661        edge: &Edge,
1662        lookup: impl Fn(&NodeId) -> Option<&'a Label>,
1663    ) -> Result<()> {
1664        let edge_type = self.edge_type(&edge.label).ok_or_else(|| {
1665            GrustError::Schema(format!("schema has no edge type '{}'", edge.label.as_str()))
1666        })?;
1667
1668        let from_label = lookup(&edge.from).ok_or_else(|| {
1669            GrustError::Schema(format!(
1670                "edge '{}' references unknown from node '{}'",
1671                edge.label.as_str(),
1672                edge.from.as_str()
1673            ))
1674        })?;
1675        let to_label = lookup(&edge.to).ok_or_else(|| {
1676            GrustError::Schema(format!(
1677                "edge '{}' references unknown to node '{}'",
1678                edge.label.as_str(),
1679                edge.to.as_str()
1680            ))
1681        })?;
1682
1683        let from_matches =
1684            |label: &Label| edge_type.from.is_empty() || edge_type.from.contains(label);
1685        let to_matches = |label: &Label| edge_type.to.is_empty() || edge_type.to.contains(label);
1686        // Undirected edge types accept their endpoint labels in either
1687        // orientation.
1688        let endpoints_ok = (from_matches(from_label) && to_matches(to_label))
1689            || (!edge_type.directed && from_matches(to_label) && to_matches(from_label));
1690        if !endpoints_ok {
1691            if !from_matches(from_label) {
1692                return Err(GrustError::Schema(format!(
1693                    "edge '{}' cannot start from node label '{}'",
1694                    edge.label.as_str(),
1695                    from_label.as_str()
1696                )));
1697            }
1698            return Err(GrustError::Schema(format!(
1699                "edge '{}' cannot end at node label '{}'",
1700                edge.label.as_str(),
1701                to_label.as_str()
1702            )));
1703        }
1704
1705        validate_props(
1706            &edge.props,
1707            &edge_type.fields,
1708            &format!("edge '{}'", edge.label.as_str()),
1709        )
1710    }
1711
1712    /// Validates an edge's label and props against the schema without
1713    /// checking endpoint nodes, for stores that persist a single edge and
1714    /// cannot cheaply resolve its endpoints.
1715    pub fn validate_edge_props(&self, edge: &Edge) -> Result<()> {
1716        let edge_type = self.edge_type(&edge.label).ok_or_else(|| {
1717            GrustError::Schema(format!("schema has no edge type '{}'", edge.label.as_str()))
1718        })?;
1719        validate_props(
1720            &edge.props,
1721            &edge_type.fields,
1722            &format!("edge '{}'", edge.label.as_str()),
1723        )
1724    }
1725}
1726
1727#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1728pub struct NodeType {
1729    pub label: Label,
1730    pub fields: Vec<Field>,
1731}
1732
1733#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1734pub struct EdgeType {
1735    pub label: Label,
1736    pub from: Vec<Label>,
1737    pub to: Vec<Label>,
1738    pub fields: Vec<Field>,
1739    pub directed: bool,
1740    pub uniqueness: EdgeUniqueness,
1741}
1742
1743#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1744pub struct Field {
1745    pub name: String,
1746    pub ty: FieldType,
1747    pub required: bool,
1748}
1749
1750#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1751pub enum FieldType {
1752    String,
1753    Int,
1754    Float,
1755    Bool,
1756    DateTime,
1757    StringArray,
1758    IntArray,
1759    FloatArray,
1760    Json,
1761}
1762
1763/// How many edges of one type may exist between a pair of nodes.
1764///
1765/// `validate_graph` enforces `FromTo` and `FromLabelTo` identically — at most
1766/// one edge of the type between a given endpoint pair (unordered when the
1767/// type is undirected). The distinction is a storage-key hint for backends
1768/// that keep all edge labels in one table: `FromLabelTo` keys include the
1769/// label, `FromTo` keys do not.
1770#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1771pub enum EdgeUniqueness {
1772    None,
1773    FromTo,
1774    FromLabelTo,
1775}
1776
1777#[derive(Clone, Debug, Default)]
1778pub struct GraphSchemaBuilder {
1779    nodes: Vec<NodeType>,
1780    edges: Vec<EdgeType>,
1781}
1782
1783impl GraphSchemaBuilder {
1784    pub fn node(mut self, label: impl Into<Label>, fields: impl Into<Vec<Field>>) -> Self {
1785        self.nodes.push(NodeType {
1786            label: label.into(),
1787            fields: fields.into(),
1788        });
1789        self
1790    }
1791
1792    pub fn edge(
1793        mut self,
1794        label: impl Into<Label>,
1795        from: impl Into<Vec<Label>>,
1796        to: impl Into<Vec<Label>>,
1797        fields: impl Into<Vec<Field>>,
1798    ) -> Self {
1799        self.edges.push(EdgeType {
1800            label: label.into(),
1801            from: from.into(),
1802            to: to.into(),
1803            fields: fields.into(),
1804            directed: true,
1805            uniqueness: EdgeUniqueness::FromLabelTo,
1806        });
1807        self
1808    }
1809
1810    pub fn edge_type(mut self, edge_type: EdgeType) -> Self {
1811        self.edges.push(edge_type);
1812        self
1813    }
1814
1815    pub fn build(self) -> GraphSchema {
1816        GraphSchema {
1817            nodes: self.nodes,
1818            edges: self.edges,
1819        }
1820    }
1821}
1822
1823impl Field {
1824    pub fn required(name: impl Into<String>, ty: FieldType) -> Self {
1825        Self {
1826            name: name.into(),
1827            ty,
1828            required: true,
1829        }
1830    }
1831
1832    pub fn optional(name: impl Into<String>, ty: FieldType) -> Self {
1833        Self {
1834            name: name.into(),
1835            ty,
1836            required: false,
1837        }
1838    }
1839}
1840
1841fn validate_props(props: &Props, fields: &[Field], context: &str) -> Result<()> {
1842    for field in fields {
1843        match props.get(&field.name) {
1844            Some(value) => validate_field_value(value, &field.ty, context, &field.name)?,
1845            None if field.required => {
1846                return Err(GrustError::Schema(format!(
1847                    "{context} missing required field '{}'",
1848                    field.name
1849                )));
1850            }
1851            None => {}
1852        }
1853    }
1854    Ok(())
1855}
1856
1857fn validate_field_value(
1858    value: &Value,
1859    ty: &FieldType,
1860    context: &str,
1861    field_name: &str,
1862) -> Result<()> {
1863    let matches = match (value, ty) {
1864        (Value::String(_), FieldType::String)
1865        | (Value::Int(_), FieldType::Int)
1866        | (Value::Float(_), FieldType::Float)
1867        | (Value::Bool(_), FieldType::Bool)
1868        | (Value::DateTime(_), FieldType::DateTime)
1869        | (Value::StringArray(_), FieldType::StringArray)
1870        | (Value::IntArray(_), FieldType::IntArray)
1871        | (Value::FloatArray(_), FieldType::FloatArray)
1872        | (_, FieldType::Json) => true,
1873        // Plain strings remain valid date-times for backward compatibility,
1874        // but must still parse as RFC 3339.
1875        (Value::String(value), FieldType::DateTime) => is_rfc3339_datetime(value),
1876        _ => false,
1877    };
1878    if matches {
1879        Ok(())
1880    } else {
1881        Err(GrustError::Schema(format!(
1882            "{context} field '{field_name}' expected {ty:?}, got {value:?}"
1883        )))
1884    }
1885}
1886
1887#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1888pub struct Traversal {
1889    pub start: Start,
1890    pub steps: Vec<Step>,
1891    pub limit: Option<u32>,
1892}
1893
1894impl Traversal {
1895    pub fn from_node(id: impl Into<NodeId>) -> Self {
1896        Self {
1897            start: Start::Node(id.into()),
1898            steps: Vec::new(),
1899            limit: None,
1900        }
1901    }
1902
1903    pub fn out(mut self, edge: impl Into<Label>) -> Self {
1904        self.steps.push(Step {
1905            direction: Direction::Out,
1906            edge: Some(edge.into()),
1907            node: None,
1908        });
1909        self
1910    }
1911
1912    pub fn in_(mut self, edge: impl Into<Label>) -> Self {
1913        self.steps.push(Step {
1914            direction: Direction::In,
1915            edge: Some(edge.into()),
1916            node: None,
1917        });
1918        self
1919    }
1920
1921    pub fn both(mut self, edge: impl Into<Label>) -> Self {
1922        self.steps.push(Step {
1923            direction: Direction::Both,
1924            edge: Some(edge.into()),
1925            node: None,
1926        });
1927        self
1928    }
1929
1930    /// Constrains the target node label of the most recent step.
1931    ///
1932    /// # Panics
1933    ///
1934    /// Panics if called before any step has been added with `out`, `in_`, or
1935    /// `both`, since there is no step for the label to apply to.
1936    pub fn to(mut self, node: impl Into<Label>) -> Self {
1937        let step = self
1938            .steps
1939            .last_mut()
1940            .expect("Traversal::to() must follow out(), in_(), or both()");
1941        step.node = Some(node.into());
1942        self
1943    }
1944
1945    pub fn limit(mut self, limit: u32) -> Self {
1946        self.limit = Some(limit);
1947        self
1948    }
1949}
1950
1951#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1952pub enum Start {
1953    Node(NodeId),
1954    NodesByLabel(Label),
1955    NodesByProperty {
1956        label: Label,
1957        key: String,
1958        value: Value,
1959    },
1960}
1961
1962#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1963pub struct Step {
1964    pub direction: Direction,
1965    pub edge: Option<Label>,
1966    pub node: Option<Label>,
1967}
1968
1969#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1970pub enum Direction {
1971    Out,
1972    In,
1973    Both,
1974}
1975
1976#[derive(Clone, Debug, Default, PartialEq)]
1977pub struct EdgeQuery {
1978    pub from: Option<NodeId>,
1979    pub to: Option<NodeId>,
1980    pub label: Option<Label>,
1981}
1982
1983/// Outcome of writing a single node or edge.
1984///
1985/// `Inserted` and `Updated` are precise only for stores that can cheaply
1986/// distinguish create from replace. Remote upsert-oriented backends commonly
1987/// return `Upserted` because doing otherwise would require an extra read or a
1988/// backend-specific write primitive. Portable callers should treat all three
1989/// written outcomes as success and should not rely on `Inserted` or `Updated`
1990/// unless they are intentionally targeting a backend that documents those
1991/// precise outcomes.
1992#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1993pub enum PutOutcome {
1994    /// The element did not exist before and was created.
1995    Inserted,
1996    /// An element with the same identity existed and was overwritten or
1997    /// merged.
1998    Updated,
1999    /// The element was written by an upsert and the backend cannot tell
2000    /// whether it was an insert or an update.
2001    Upserted,
2002    /// The element was dropped by a dedupe policy and nothing was written.
2003    Deduped,
2004}
2005
2006impl PutOutcome {
2007    /// True unless the element was deduped away.
2008    pub fn written(self) -> bool {
2009        !matches!(self, Self::Deduped)
2010    }
2011}
2012
2013/// Counts of nodes and edges written by a bulk load. Each count reflects an
2014/// upsert applied to the backend, not distinct newly created elements.
2015#[derive(Clone, Debug, Default, Eq, PartialEq)]
2016pub struct LoadReport {
2017    pub nodes: usize,
2018    pub edges: usize,
2019}
2020
2021#[async_trait]
2022pub trait GraphStore: Send + Sync {
2023    /// Applies backend schema metadata.
2024    ///
2025    /// `GraphSchema` is a portable declaration of expected node labels, edge
2026    /// labels, fields, endpoint labels, direction, and uniqueness. The default
2027    /// implementation is a no-op for schemaless stores. Backend implementations
2028    /// may use the schema for validation, typed native tables, views, indexes,
2029    /// generated query shapes, or database-native schema definitions.
2030    ///
2031    /// Applying a schema does not imply the same enforcement guarantee on every
2032    /// backend. Callers that need portable preflight validation should call
2033    /// [`GraphSchema::validate_graph`] before writing, or use
2034    /// [`GraphStore::put_typed_graph`], which does that validation before
2035    /// applying the backend schema and writing the graph. Individual backends
2036    /// document whether they also validate each subsequent write at runtime.
2037    async fn apply_schema(&self, _schema: &GraphSchema) -> Result<()> {
2038        Ok(())
2039    }
2040
2041    /// Writes one node.
2042    ///
2043    /// The returned [`PutOutcome`] reports the most precise result the backend
2044    /// can provide. Remote upsert backends generally return
2045    /// [`PutOutcome::Upserted`] for both inserts and updates.
2046    async fn put_node(&self, node: &Node) -> Result<PutOutcome>;
2047
2048    /// Writes one edge.
2049    ///
2050    /// The returned [`PutOutcome`] reports the most precise result the backend
2051    /// can provide. Remote upsert backends generally return
2052    /// [`PutOutcome::Upserted`] for both inserts and updates.
2053    async fn put_edge(&self, edge: &Edge) -> Result<PutOutcome>;
2054
2055    async fn put_graph(&self, graph: &Graph) -> Result<LoadReport> {
2056        let mut report = LoadReport::default();
2057        for node in &graph.nodes {
2058            if self.put_node(node).await?.written() {
2059                report.nodes += 1;
2060            }
2061        }
2062        for edge in &graph.edges {
2063            if self.put_edge(edge).await?.written() {
2064                report.edges += 1;
2065            }
2066        }
2067        Ok(report)
2068    }
2069
2070    async fn put_typed_graph(&self, schema: &GraphSchema, graph: &Graph) -> Result<LoadReport> {
2071        schema.validate_graph(graph)?;
2072        self.apply_schema(schema).await?;
2073        self.put_graph(graph).await
2074    }
2075
2076    async fn get_node(&self, id: &NodeId) -> Result<Option<Node>>;
2077
2078    /// Reads multiple nodes by ID.
2079    ///
2080    /// The default implementation preserves the input order and calls
2081    /// [`GraphStore::get_node`] once per ID. Backends with a native batch-read
2082    /// path should override this to avoid per-node round trips during traversal
2083    /// and other fan-out reads.
2084    async fn get_nodes(&self, ids: &[NodeId]) -> Result<Vec<Node>> {
2085        let mut nodes = Vec::new();
2086        for id in ids {
2087            if let Some(node) = self.get_node(id).await? {
2088                nodes.push(node);
2089            }
2090        }
2091        Ok(nodes)
2092    }
2093
2094    async fn get_edges(&self, query: EdgeQuery) -> Result<Vec<Edge>>;
2095    async fn traverse(&self, traversal: Traversal) -> Result<Vec<Node>>;
2096}
2097
2098#[async_trait]
2099pub trait GraphAdminStore: GraphStore {
2100    async fn bootstrap(&self) -> Result<()> {
2101        Ok(())
2102    }
2103
2104    async fn clear(&self) -> Result<()>;
2105}
2106
2107/// A single incremental change to a graph, for delta-oriented pipelines.
2108#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2109pub enum GraphMutation {
2110    UpsertNode(Node),
2111    DeleteNode(NodeId),
2112    UpsertEdge(Edge),
2113    DeleteEdge {
2114        from: NodeId,
2115        label: Label,
2116        to: NodeId,
2117    },
2118}
2119
2120/// Incremental mutation support for stores that can delete elements.
2121///
2122/// Deletes are idempotent: removing an element that does not exist is not an
2123/// error.
2124#[async_trait]
2125pub trait GraphMutationStore: GraphStore {
2126    /// Deletes a node and all edges incident to it.
2127    async fn delete_node(&self, id: &NodeId) -> Result<()>;
2128
2129    /// Deletes the edge(s) matching `(from, label, to)`.
2130    async fn delete_edge(&self, from: &NodeId, label: &Label, to: &NodeId) -> Result<()>;
2131
2132    /// Applies mutations in order, stopping at the first error.
2133    ///
2134    /// The default implementation calls the single-mutation methods one at a
2135    /// time and is not atomic: if a later mutation fails, earlier successful
2136    /// mutations are not rolled back. Backends with transaction support should
2137    /// override this method and apply the whole slice in one transaction.
2138    async fn apply_mutations(&self, mutations: &[GraphMutation]) -> Result<()> {
2139        for mutation in mutations {
2140            match mutation {
2141                GraphMutation::UpsertNode(node) => {
2142                    self.put_node(node).await?;
2143                }
2144                GraphMutation::DeleteNode(id) => self.delete_node(id).await?,
2145                GraphMutation::UpsertEdge(edge) => {
2146                    self.put_edge(edge).await?;
2147                }
2148                GraphMutation::DeleteEdge { from, label, to } => {
2149                    self.delete_edge(from, label, to).await?
2150                }
2151            }
2152        }
2153        Ok(())
2154    }
2155}
2156
2157pub mod prelude {
2158    pub use crate::{
2159        Direction, Edge, EdgeId, EdgePolicy, EdgeQuery, EdgeType, EdgeUniqueness, Field, FieldType,
2160        Graph, GraphAdminStore, GraphBuilder, GraphIndex, GraphMutation, GraphMutationStore,
2161        GraphSchema, GraphSchemaBuilder, GraphStore, GrustError, Label, LoadReport, Node, NodeId,
2162        NodeType, Props, PutOutcome, Result, RfcDate, Start, Step, Traversal, Value, edge_key,
2163        relationship_type, schema_identifier,
2164    };
2165
2166    #[cfg(feature = "typed-garde")]
2167    pub use crate::typed::{TypedEdge, TypedGraphBuilder, TypedNode, garde, props_from_serialize};
2168
2169    #[cfg(feature = "typed-zod-rs")]
2170    pub use crate::typed::{parse_typed_json, parse_typed_json_with, zod_rs};
2171}
2172
2173#[cfg(test)]
2174mod tests;