Skip to main content

grust_core/
lib.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
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            format!(
891                "{}\u{1f}{}\u{1f}{}",
892                edge.from.as_str(),
893                edge.label.as_str(),
894                edge.to.as_str()
895            )
896        })
897}
898
899#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
900pub struct Graph {
901    pub nodes: Vec<Node>,
902    pub edges: Vec<Edge>,
903}
904
905impl Graph {
906    pub fn new(nodes: Vec<Node>, edges: Vec<Edge>) -> Self {
907        Self { nodes, edges }
908    }
909
910    pub fn from_yaml(yaml: &str) -> Result<Self> {
911        yaml::graph_from_yaml(yaml)
912    }
913
914    pub fn to_yaml(&self) -> Result<String> {
915        yaml::graph_to_yaml(self)
916    }
917
918    pub fn from_json(json: &str) -> Result<Self> {
919        json::graph_from_json(json)
920    }
921
922    pub fn to_json(&self) -> Result<String> {
923        json::graph_to_json(self)
924    }
925
926    pub fn from_xml(xml: &str) -> Result<Self> {
927        xml::graph_from_xml(xml)
928    }
929
930    pub fn to_xml(&self) -> Result<String> {
931        xml::graph_to_xml(self)
932    }
933
934    pub fn builder() -> GraphBuilder {
935        GraphBuilder::new()
936    }
937}
938
939mod graph_doc {
940    use std::collections::{BTreeMap, BTreeSet};
941
942    use serde::{Deserialize, Serialize};
943
944    use crate::{Edge, EdgeId, Graph, GrustError, Label, Node, NodeId, Props, Value};
945
946    #[derive(Debug, Serialize, Deserialize)]
947    pub(super) struct GraphDoc {
948        #[serde(default)]
949        pub(super) nodes: Vec<NodeDoc>,
950        #[serde(default)]
951        pub(super) edges: Vec<EdgeDoc>,
952    }
953
954    #[derive(Debug, Serialize, Deserialize)]
955    pub(super) struct NodeDoc {
956        pub(super) id: NodeId,
957        pub(super) label: Label,
958        #[serde(default, deserialize_with = "deserialize_props")]
959        pub(super) props: Props,
960    }
961
962    #[derive(Debug, Serialize, Deserialize)]
963    pub(super) struct NodeDocOut {
964        pub(super) id: NodeId,
965        pub(super) label: Label,
966        #[serde(default)]
967        pub(super) props: Props,
968    }
969
970    #[derive(Debug, Serialize, Deserialize)]
971    pub(super) struct EdgeDoc {
972        #[serde(default)]
973        pub(super) id: Option<EdgeId>,
974        pub(super) label: Label,
975        pub(super) from: NodeId,
976        pub(super) to: NodeId,
977        #[serde(default, deserialize_with = "deserialize_props")]
978        pub(super) props: Props,
979    }
980
981    #[derive(Debug, Serialize, Deserialize)]
982    pub(super) struct EdgeDocOut {
983        #[serde(default)]
984        pub(super) id: Option<EdgeId>,
985        pub(super) label: Label,
986        pub(super) from: NodeId,
987        pub(super) to: NodeId,
988        #[serde(default)]
989        pub(super) props: Props,
990    }
991
992    pub(super) fn graph_from_doc(doc: GraphDoc) -> super::Result<Graph> {
993        let mut ids = BTreeSet::new();
994        for node in &doc.nodes {
995            if !ids.insert(node.id.clone()) {
996                return Err(GrustError::Schema(format!(
997                    "duplicate node id '{}'",
998                    node.id
999                )));
1000            }
1001        }
1002
1003        let mut edges = Vec::with_capacity(doc.edges.len());
1004        for edge in doc.edges {
1005            if !ids.contains(&edge.from) {
1006                return Err(GrustError::Schema(format!(
1007                    "edge '{}' references unknown from node '{}'",
1008                    edge.label, edge.from
1009                )));
1010            }
1011            if !ids.contains(&edge.to) {
1012                return Err(GrustError::Schema(format!(
1013                    "edge '{}' references unknown to node '{}'",
1014                    edge.label, edge.to
1015                )));
1016            }
1017
1018            let mut graph_edge = Edge::new(edge.label, edge.from, edge.to, edge.props);
1019            graph_edge.id = edge.id;
1020            edges.push(graph_edge);
1021        }
1022
1023        let nodes = doc
1024            .nodes
1025            .into_iter()
1026            .map(|node| Node::new(node.label, node.id, node.props))
1027            .collect();
1028
1029        Ok(Graph::new(nodes, edges))
1030    }
1031
1032    pub(super) fn graph_to_doc(graph: &Graph) -> GraphDocOut {
1033        GraphDocOut {
1034            nodes: graph
1035                .nodes
1036                .iter()
1037                .map(|node| NodeDocOut {
1038                    id: node.id.clone(),
1039                    label: node.label.clone(),
1040                    props: without_generated_id(&node.props, &node.id),
1041                })
1042                .collect(),
1043            edges: graph
1044                .edges
1045                .iter()
1046                .map(|edge| EdgeDocOut {
1047                    id: edge.id.clone(),
1048                    label: edge.label.clone(),
1049                    from: edge.from.clone(),
1050                    to: edge.to.clone(),
1051                    props: edge.props.clone(),
1052                })
1053                .collect(),
1054        }
1055    }
1056
1057    fn without_generated_id(props: &Props, id: &NodeId) -> Props {
1058        let mut props = props.clone();
1059        if props.get("id") == Some(&Value::from(id.as_str())) {
1060            props.remove("id");
1061        }
1062        props
1063    }
1064
1065    fn deserialize_props<'de, D>(deserializer: D) -> std::result::Result<Props, D::Error>
1066    where
1067        D: serde::Deserializer<'de>,
1068    {
1069        let raw = BTreeMap::<String, serde_json::Value>::deserialize(deserializer)?;
1070        raw.into_iter()
1071            .map(|(key, value)| {
1072                value_from_json(value)
1073                    .map(|value| (key, value))
1074                    .map_err(serde::de::Error::custom)
1075            })
1076            .collect()
1077    }
1078
1079    fn value_from_json(value: serde_json::Value) -> std::result::Result<Value, String> {
1080        if let serde_json::Value::Object(mapping) = &value
1081            && mapping.contains_key("type")
1082            && mapping.contains_key("value")
1083        {
1084            return serde_json::from_value(value)
1085                .map_err(|err| format!("invalid tagged Grust value: {err}"));
1086        }
1087
1088        Ok(Value::from_json(value))
1089    }
1090
1091    #[derive(Debug, Serialize, Deserialize)]
1092    pub(super) struct GraphDocOut {
1093        pub(super) nodes: Vec<NodeDocOut>,
1094        pub(super) edges: Vec<EdgeDocOut>,
1095    }
1096}
1097
1098mod yaml {
1099    use crate::{Graph, GrustError};
1100
1101    pub(super) fn graph_from_yaml(yaml: &str) -> super::Result<Graph> {
1102        let doc: super::graph_doc::GraphDoc = serde_yaml::from_str(yaml)
1103            .map_err(|err| GrustError::Serialization(format!("YAML parse error: {err}")))?;
1104        super::graph_doc::graph_from_doc(doc)
1105    }
1106
1107    pub(super) fn graph_to_yaml(graph: &Graph) -> super::Result<String> {
1108        serde_yaml::to_string(&super::graph_doc::graph_to_doc(graph))
1109            .map_err(|err| GrustError::Serialization(format!("YAML serialization error: {err}")))
1110    }
1111}
1112
1113mod json {
1114    use crate::{Graph, GrustError};
1115
1116    pub(super) fn graph_from_json(json: &str) -> super::Result<Graph> {
1117        let doc: super::graph_doc::GraphDoc = serde_json::from_str(json)
1118            .map_err(|err| GrustError::Serialization(format!("JSON parse error: {err}")))?;
1119        super::graph_doc::graph_from_doc(doc)
1120    }
1121
1122    pub(super) fn graph_to_json(graph: &Graph) -> super::Result<String> {
1123        serde_json::to_string_pretty(&super::graph_doc::graph_to_doc(graph))
1124            .map_err(|err| GrustError::Serialization(format!("JSON serialization error: {err}")))
1125    }
1126}
1127
1128mod xml {
1129    use serde::{Deserialize, Serialize};
1130
1131    use crate::{Edge, EdgeId, Graph, GrustError, Label, Node, NodeId, Props, Value};
1132
1133    #[derive(Debug, Serialize, Deserialize)]
1134    #[serde(rename = "graph")]
1135    struct GraphXml {
1136        #[serde(default)]
1137        nodes: NodesXml,
1138        #[serde(default)]
1139        edges: EdgesXml,
1140    }
1141
1142    #[derive(Debug, Default, Serialize, Deserialize)]
1143    struct NodesXml {
1144        #[serde(rename = "node", default)]
1145        items: Vec<NodeXml>,
1146    }
1147
1148    #[derive(Debug, Default, Serialize, Deserialize)]
1149    struct EdgesXml {
1150        #[serde(rename = "edge", default)]
1151        items: Vec<EdgeXml>,
1152    }
1153
1154    #[derive(Debug, Serialize, Deserialize)]
1155    struct NodeXml {
1156        id: NodeId,
1157        label: Label,
1158        #[serde(default)]
1159        props: PropsXml,
1160    }
1161
1162    #[derive(Debug, Serialize, Deserialize)]
1163    struct EdgeXml {
1164        #[serde(default, skip_serializing_if = "Option::is_none")]
1165        id: Option<EdgeId>,
1166        label: Label,
1167        from: NodeId,
1168        to: NodeId,
1169        #[serde(default)]
1170        props: PropsXml,
1171    }
1172
1173    #[derive(Debug, Default, Serialize, Deserialize)]
1174    struct PropsXml {
1175        #[serde(rename = "prop", default)]
1176        items: Vec<PropXml>,
1177    }
1178
1179    #[derive(Debug, Serialize, Deserialize)]
1180    struct PropXml {
1181        key: String,
1182        value: Value,
1183    }
1184
1185    pub(super) fn graph_from_xml(xml: &str) -> super::Result<Graph> {
1186        let doc: GraphXml = quick_xml::de::from_str(xml)
1187            .map_err(|err| GrustError::Serialization(format!("XML parse error: {err}")))?;
1188        super::graph_doc::graph_from_doc(doc.into())
1189    }
1190
1191    pub(super) fn graph_to_xml(graph: &Graph) -> super::Result<String> {
1192        quick_xml::se::to_string(&GraphXml::from(graph))
1193            .map_err(|err| GrustError::Serialization(format!("XML serialization error: {err}")))
1194    }
1195
1196    impl From<GraphXml> for super::graph_doc::GraphDoc {
1197        fn from(value: GraphXml) -> Self {
1198            Self {
1199                nodes: value.nodes.items.into_iter().map(Into::into).collect(),
1200                edges: value.edges.items.into_iter().map(Into::into).collect(),
1201            }
1202        }
1203    }
1204
1205    impl From<NodeXml> for super::graph_doc::NodeDoc {
1206        fn from(value: NodeXml) -> Self {
1207            Self {
1208                id: value.id,
1209                label: value.label,
1210                props: value.props.into(),
1211            }
1212        }
1213    }
1214
1215    impl From<EdgeXml> for super::graph_doc::EdgeDoc {
1216        fn from(value: EdgeXml) -> Self {
1217            Self {
1218                id: value.id,
1219                label: value.label,
1220                from: value.from,
1221                to: value.to,
1222                props: value.props.into(),
1223            }
1224        }
1225    }
1226
1227    impl From<PropsXml> for Props {
1228        fn from(value: PropsXml) -> Self {
1229            value
1230                .items
1231                .into_iter()
1232                .map(|prop| (prop.key, prop.value))
1233                .collect()
1234        }
1235    }
1236
1237    impl From<&Graph> for GraphXml {
1238        fn from(graph: &Graph) -> Self {
1239            Self {
1240                nodes: NodesXml {
1241                    items: graph.nodes.iter().map(NodeXml::from).collect(),
1242                },
1243                edges: EdgesXml {
1244                    items: graph.edges.iter().map(EdgeXml::from).collect(),
1245                },
1246            }
1247        }
1248    }
1249
1250    impl From<&Node> for NodeXml {
1251        fn from(node: &Node) -> Self {
1252            let props = super::graph_doc::graph_to_doc(&Graph::new(vec![node.clone()], Vec::new()))
1253                .nodes
1254                .into_iter()
1255                .next()
1256                .expect("node exists")
1257                .props;
1258            Self {
1259                id: node.id.clone(),
1260                label: node.label.clone(),
1261                props: props.into(),
1262            }
1263        }
1264    }
1265
1266    impl From<&Edge> for EdgeXml {
1267        fn from(edge: &Edge) -> Self {
1268            Self {
1269                id: edge.id.clone(),
1270                label: edge.label.clone(),
1271                from: edge.from.clone(),
1272                to: edge.to.clone(),
1273                props: edge.props.clone().into(),
1274            }
1275        }
1276    }
1277
1278    impl From<Props> for PropsXml {
1279        fn from(value: Props) -> Self {
1280            Self {
1281                items: value
1282                    .into_iter()
1283                    .map(|(key, value)| PropXml { key, value })
1284                    .collect(),
1285            }
1286        }
1287    }
1288}
1289
1290#[derive(Clone, Debug, Default, Eq, PartialEq)]
1291pub enum EdgePolicy {
1292    AllowDuplicates,
1293    #[default]
1294    DedupeByFromLabelTo,
1295}
1296
1297#[derive(Clone, Debug, Default)]
1298pub struct GraphBuilder {
1299    nodes: BTreeMap<NodeId, Node>,
1300    edges: Vec<Edge>,
1301    edge_keys: BTreeSet<(NodeId, Label, NodeId)>,
1302    edge_policy: EdgePolicy,
1303}
1304
1305impl GraphBuilder {
1306    pub fn new() -> Self {
1307        Self::default()
1308    }
1309
1310    pub fn edge_policy(mut self, edge_policy: EdgePolicy) -> Self {
1311        self.edge_policy = edge_policy;
1312        self
1313    }
1314
1315    pub fn node<'a>(
1316        &'a mut self,
1317        label: impl Into<Label>,
1318        id: impl Into<NodeId>,
1319    ) -> NodeBuilder<'a> {
1320        NodeBuilder {
1321            builder: self,
1322            label: label.into(),
1323            id: id.into(),
1324            props: Props::new(),
1325        }
1326    }
1327
1328    pub fn edge<'a>(
1329        &'a mut self,
1330        label: impl Into<Label>,
1331        from: impl Into<NodeId>,
1332        to: impl Into<NodeId>,
1333    ) -> EdgeBuilder<'a> {
1334        EdgeBuilder {
1335            builder: self,
1336            id: None,
1337            label: label.into(),
1338            from: from.into(),
1339            to: to.into(),
1340            props: Props::new(),
1341        }
1342    }
1343
1344    /// Adds a node, merging with any existing node that has the same id.
1345    ///
1346    /// If a node with the same id and the same label already exists, the new
1347    /// props are merged in (new values win). If a node with the same id but a
1348    /// different label exists, the new node replaces it entirely (last write
1349    /// wins, matching `GraphStore::put_node` overwrite semantics).
1350    pub fn add_node(&mut self, node: Node) -> NodeId {
1351        let id = node.id.clone();
1352        self.nodes
1353            .entry(id.clone())
1354            .and_modify(|existing| {
1355                if existing.label == node.label {
1356                    existing.props.extend(node.props.clone());
1357                } else {
1358                    *existing = node.clone();
1359                }
1360            })
1361            .or_insert(node);
1362        id
1363    }
1364
1365    /// Adds an edge, reporting whether it was stored or dropped by the
1366    /// builder's [`EdgePolicy`].
1367    pub fn add_edge(&mut self, edge: Edge) -> PutOutcome {
1368        match self.edge_policy {
1369            EdgePolicy::AllowDuplicates => {
1370                self.edges.push(edge);
1371                PutOutcome::Inserted
1372            }
1373            EdgePolicy::DedupeByFromLabelTo => {
1374                let key = (edge.from.clone(), edge.label.clone(), edge.to.clone());
1375                if self.edge_keys.insert(key) {
1376                    self.edges.push(edge);
1377                    PutOutcome::Inserted
1378                } else {
1379                    PutOutcome::Deduped
1380                }
1381            }
1382        }
1383    }
1384
1385    #[must_use = "discarding this means the graph was not built"]
1386    pub fn build(self) -> Graph {
1387        Graph {
1388            nodes: self.nodes.into_values().collect(),
1389            edges: self.edges,
1390        }
1391    }
1392}
1393
1394pub struct NodeBuilder<'a> {
1395    builder: &'a mut GraphBuilder,
1396    label: Label,
1397    id: NodeId,
1398    props: Props,
1399}
1400
1401impl<'a> NodeBuilder<'a> {
1402    pub fn prop(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1403        self.props.insert(key.into(), value.into());
1404        self
1405    }
1406
1407    pub fn props(mut self, props: Props) -> Self {
1408        self.props.extend(props);
1409        self
1410    }
1411
1412    #[must_use = "discarding this means the node was not added to the builder"]
1413    pub fn finish(self) -> NodeId {
1414        let node = Node::new(self.label, self.id, self.props);
1415        self.builder.add_node(node)
1416    }
1417}
1418
1419pub struct EdgeBuilder<'a> {
1420    builder: &'a mut GraphBuilder,
1421    id: Option<EdgeId>,
1422    label: Label,
1423    from: NodeId,
1424    to: NodeId,
1425    props: Props,
1426}
1427
1428impl<'a> EdgeBuilder<'a> {
1429    pub fn id(mut self, id: impl Into<EdgeId>) -> Self {
1430        self.id = Some(id.into());
1431        self
1432    }
1433
1434    pub fn prop(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1435        self.props.insert(key.into(), value.into());
1436        self
1437    }
1438
1439    pub fn props(mut self, props: Props) -> Self {
1440        self.props.extend(props);
1441        self
1442    }
1443
1444    #[must_use = "discarding this means the edge was not added to the builder"]
1445    pub fn finish(self) -> PutOutcome {
1446        let mut edge = Edge::new(self.label, self.from, self.to, self.props);
1447        edge.id = self.id;
1448        self.builder.add_edge(edge)
1449    }
1450}
1451
1452#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1453pub struct GraphSchema {
1454    pub nodes: Vec<NodeType>,
1455    pub edges: Vec<EdgeType>,
1456}
1457
1458impl GraphSchema {
1459    pub fn builder() -> GraphSchemaBuilder {
1460        GraphSchemaBuilder::default()
1461    }
1462
1463    pub fn node_type(&self, label: &Label) -> Option<&NodeType> {
1464        self.nodes
1465            .iter()
1466            .find(|node_type| &node_type.label == label)
1467    }
1468
1469    pub fn edge_type(&self, label: &Label) -> Option<&EdgeType> {
1470        self.edges
1471            .iter()
1472            .find(|edge_type| &edge_type.label == label)
1473    }
1474
1475    pub fn validate_graph(&self, graph: &Graph) -> Result<()> {
1476        for node in &graph.nodes {
1477            self.validate_node(node)?;
1478        }
1479        let labels: BTreeMap<&NodeId, &Label> = graph
1480            .nodes
1481            .iter()
1482            .map(|node| (&node.id, &node.label))
1483            .collect();
1484        for edge in &graph.edges {
1485            self.validate_edge_with(edge, |id| labels.get(id).copied())?;
1486        }
1487        self.validate_edge_uniqueness(graph)
1488    }
1489
1490    /// Enforces each edge type's [`EdgeUniqueness`]: at most one edge of the
1491    /// type between a given endpoint pair (unordered for undirected types).
1492    fn validate_edge_uniqueness(&self, graph: &Graph) -> Result<()> {
1493        let mut seen = BTreeSet::new();
1494        for edge in &graph.edges {
1495            let Some(edge_type) = self.edge_type(&edge.label) else {
1496                continue;
1497            };
1498            if edge_type.uniqueness == EdgeUniqueness::None {
1499                continue;
1500            }
1501            let (a, b) = if edge_type.directed || edge.from <= edge.to {
1502                (&edge.from, &edge.to)
1503            } else {
1504                (&edge.to, &edge.from)
1505            };
1506            if !seen.insert((edge.label.clone(), a.clone(), b.clone())) {
1507                return Err(GrustError::Schema(format!(
1508                    "duplicate edge '{}' between '{}' and '{}' violates {:?} uniqueness",
1509                    edge.label.as_str(),
1510                    a.as_str(),
1511                    b.as_str(),
1512                    edge_type.uniqueness
1513                )));
1514            }
1515        }
1516        Ok(())
1517    }
1518
1519    pub fn validate_node(&self, node: &Node) -> Result<()> {
1520        let node_type = self.node_type(&node.label).ok_or_else(|| {
1521            GrustError::Schema(format!("schema has no node type '{}'", node.label.as_str()))
1522        })?;
1523        validate_props(
1524            &node.props,
1525            &node_type.fields,
1526            &format!("node '{}'", node.id.as_str()),
1527        )
1528    }
1529
1530    pub fn validate_edge(&self, edge: &Edge, graph: &Graph) -> Result<()> {
1531        self.validate_edge_with(edge, |id| {
1532            graph
1533                .nodes
1534                .iter()
1535                .find(|node| &node.id == id)
1536                .map(|node| &node.label)
1537        })
1538    }
1539
1540    /// Validates an edge using a label lookup instead of a full `Graph`, so
1541    /// stores can validate against their own node index without cloning.
1542    pub fn validate_edge_with<'a>(
1543        &self,
1544        edge: &Edge,
1545        lookup: impl Fn(&NodeId) -> Option<&'a Label>,
1546    ) -> Result<()> {
1547        let edge_type = self.edge_type(&edge.label).ok_or_else(|| {
1548            GrustError::Schema(format!("schema has no edge type '{}'", edge.label.as_str()))
1549        })?;
1550
1551        let from_label = lookup(&edge.from).ok_or_else(|| {
1552            GrustError::Schema(format!(
1553                "edge '{}' references unknown from node '{}'",
1554                edge.label.as_str(),
1555                edge.from.as_str()
1556            ))
1557        })?;
1558        let to_label = lookup(&edge.to).ok_or_else(|| {
1559            GrustError::Schema(format!(
1560                "edge '{}' references unknown to node '{}'",
1561                edge.label.as_str(),
1562                edge.to.as_str()
1563            ))
1564        })?;
1565
1566        let from_matches =
1567            |label: &Label| edge_type.from.is_empty() || edge_type.from.contains(label);
1568        let to_matches = |label: &Label| edge_type.to.is_empty() || edge_type.to.contains(label);
1569        // Undirected edge types accept their endpoint labels in either
1570        // orientation.
1571        let endpoints_ok = (from_matches(from_label) && to_matches(to_label))
1572            || (!edge_type.directed && from_matches(to_label) && to_matches(from_label));
1573        if !endpoints_ok {
1574            if !from_matches(from_label) {
1575                return Err(GrustError::Schema(format!(
1576                    "edge '{}' cannot start from node label '{}'",
1577                    edge.label.as_str(),
1578                    from_label.as_str()
1579                )));
1580            }
1581            return Err(GrustError::Schema(format!(
1582                "edge '{}' cannot end at node label '{}'",
1583                edge.label.as_str(),
1584                to_label.as_str()
1585            )));
1586        }
1587
1588        validate_props(
1589            &edge.props,
1590            &edge_type.fields,
1591            &format!("edge '{}'", edge.label.as_str()),
1592        )
1593    }
1594
1595    /// Validates an edge's label and props against the schema without
1596    /// checking endpoint nodes, for stores that persist a single edge and
1597    /// cannot cheaply resolve its endpoints.
1598    pub fn validate_edge_props(&self, edge: &Edge) -> Result<()> {
1599        let edge_type = self.edge_type(&edge.label).ok_or_else(|| {
1600            GrustError::Schema(format!("schema has no edge type '{}'", edge.label.as_str()))
1601        })?;
1602        validate_props(
1603            &edge.props,
1604            &edge_type.fields,
1605            &format!("edge '{}'", edge.label.as_str()),
1606        )
1607    }
1608}
1609
1610#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1611pub struct NodeType {
1612    pub label: Label,
1613    pub fields: Vec<Field>,
1614}
1615
1616#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1617pub struct EdgeType {
1618    pub label: Label,
1619    pub from: Vec<Label>,
1620    pub to: Vec<Label>,
1621    pub fields: Vec<Field>,
1622    pub directed: bool,
1623    pub uniqueness: EdgeUniqueness,
1624}
1625
1626#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1627pub struct Field {
1628    pub name: String,
1629    pub ty: FieldType,
1630    pub required: bool,
1631}
1632
1633#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1634pub enum FieldType {
1635    String,
1636    Int,
1637    Float,
1638    Bool,
1639    DateTime,
1640    StringArray,
1641    IntArray,
1642    FloatArray,
1643    Json,
1644}
1645
1646/// How many edges of one type may exist between a pair of nodes.
1647///
1648/// `validate_graph` enforces `FromTo` and `FromLabelTo` identically — at most
1649/// one edge of the type between a given endpoint pair (unordered when the
1650/// type is undirected). The distinction is a storage-key hint for backends
1651/// that keep all edge labels in one table: `FromLabelTo` keys include the
1652/// label, `FromTo` keys do not.
1653#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1654pub enum EdgeUniqueness {
1655    None,
1656    FromTo,
1657    FromLabelTo,
1658}
1659
1660#[derive(Clone, Debug, Default)]
1661pub struct GraphSchemaBuilder {
1662    nodes: Vec<NodeType>,
1663    edges: Vec<EdgeType>,
1664}
1665
1666impl GraphSchemaBuilder {
1667    pub fn node(mut self, label: impl Into<Label>, fields: impl Into<Vec<Field>>) -> Self {
1668        self.nodes.push(NodeType {
1669            label: label.into(),
1670            fields: fields.into(),
1671        });
1672        self
1673    }
1674
1675    pub fn edge(
1676        mut self,
1677        label: impl Into<Label>,
1678        from: impl Into<Vec<Label>>,
1679        to: impl Into<Vec<Label>>,
1680        fields: impl Into<Vec<Field>>,
1681    ) -> Self {
1682        self.edges.push(EdgeType {
1683            label: label.into(),
1684            from: from.into(),
1685            to: to.into(),
1686            fields: fields.into(),
1687            directed: true,
1688            uniqueness: EdgeUniqueness::FromLabelTo,
1689        });
1690        self
1691    }
1692
1693    pub fn edge_type(mut self, edge_type: EdgeType) -> Self {
1694        self.edges.push(edge_type);
1695        self
1696    }
1697
1698    pub fn build(self) -> GraphSchema {
1699        GraphSchema {
1700            nodes: self.nodes,
1701            edges: self.edges,
1702        }
1703    }
1704}
1705
1706impl Field {
1707    pub fn required(name: impl Into<String>, ty: FieldType) -> Self {
1708        Self {
1709            name: name.into(),
1710            ty,
1711            required: true,
1712        }
1713    }
1714
1715    pub fn optional(name: impl Into<String>, ty: FieldType) -> Self {
1716        Self {
1717            name: name.into(),
1718            ty,
1719            required: false,
1720        }
1721    }
1722}
1723
1724fn validate_props(props: &Props, fields: &[Field], context: &str) -> Result<()> {
1725    for field in fields {
1726        match props.get(&field.name) {
1727            Some(value) => validate_field_value(value, &field.ty, context, &field.name)?,
1728            None if field.required => {
1729                return Err(GrustError::Schema(format!(
1730                    "{context} missing required field '{}'",
1731                    field.name
1732                )));
1733            }
1734            None => {}
1735        }
1736    }
1737    Ok(())
1738}
1739
1740fn validate_field_value(
1741    value: &Value,
1742    ty: &FieldType,
1743    context: &str,
1744    field_name: &str,
1745) -> Result<()> {
1746    let matches = match (value, ty) {
1747        (Value::String(_), FieldType::String)
1748        | (Value::Int(_), FieldType::Int)
1749        | (Value::Float(_), FieldType::Float)
1750        | (Value::Bool(_), FieldType::Bool)
1751        | (Value::DateTime(_), FieldType::DateTime)
1752        | (Value::StringArray(_), FieldType::StringArray)
1753        | (Value::IntArray(_), FieldType::IntArray)
1754        | (Value::FloatArray(_), FieldType::FloatArray)
1755        | (_, FieldType::Json) => true,
1756        // Plain strings remain valid date-times for backward compatibility,
1757        // but must still parse as RFC 3339.
1758        (Value::String(value), FieldType::DateTime) => is_rfc3339_datetime(value),
1759        _ => false,
1760    };
1761    if matches {
1762        Ok(())
1763    } else {
1764        Err(GrustError::Schema(format!(
1765            "{context} field '{field_name}' expected {ty:?}, got {value:?}"
1766        )))
1767    }
1768}
1769
1770#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1771pub struct Traversal {
1772    pub start: Start,
1773    pub steps: Vec<Step>,
1774    pub limit: Option<u32>,
1775}
1776
1777impl Traversal {
1778    pub fn from_node(id: impl Into<NodeId>) -> Self {
1779        Self {
1780            start: Start::Node(id.into()),
1781            steps: Vec::new(),
1782            limit: None,
1783        }
1784    }
1785
1786    pub fn out(mut self, edge: impl Into<Label>) -> Self {
1787        self.steps.push(Step {
1788            direction: Direction::Out,
1789            edge: Some(edge.into()),
1790            node: None,
1791        });
1792        self
1793    }
1794
1795    pub fn in_(mut self, edge: impl Into<Label>) -> Self {
1796        self.steps.push(Step {
1797            direction: Direction::In,
1798            edge: Some(edge.into()),
1799            node: None,
1800        });
1801        self
1802    }
1803
1804    pub fn both(mut self, edge: impl Into<Label>) -> Self {
1805        self.steps.push(Step {
1806            direction: Direction::Both,
1807            edge: Some(edge.into()),
1808            node: None,
1809        });
1810        self
1811    }
1812
1813    /// Constrains the target node label of the most recent step.
1814    ///
1815    /// # Panics
1816    ///
1817    /// Panics if called before any step has been added with `out`, `in_`, or
1818    /// `both`, since there is no step for the label to apply to.
1819    pub fn to(mut self, node: impl Into<Label>) -> Self {
1820        let step = self
1821            .steps
1822            .last_mut()
1823            .expect("Traversal::to() must follow out(), in_(), or both()");
1824        step.node = Some(node.into());
1825        self
1826    }
1827
1828    pub fn limit(mut self, limit: u32) -> Self {
1829        self.limit = Some(limit);
1830        self
1831    }
1832}
1833
1834#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1835pub enum Start {
1836    Node(NodeId),
1837    NodesByLabel(Label),
1838    NodesByProperty {
1839        label: Label,
1840        key: String,
1841        value: Value,
1842    },
1843}
1844
1845#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1846pub struct Step {
1847    pub direction: Direction,
1848    pub edge: Option<Label>,
1849    pub node: Option<Label>,
1850}
1851
1852#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1853pub enum Direction {
1854    Out,
1855    In,
1856    Both,
1857}
1858
1859#[derive(Clone, Debug, Default, PartialEq)]
1860pub struct EdgeQuery {
1861    pub from: Option<NodeId>,
1862    pub to: Option<NodeId>,
1863    pub label: Option<Label>,
1864}
1865
1866/// Outcome of writing a single node or edge.
1867///
1868/// `Inserted` and `Updated` are precise only for stores that can cheaply
1869/// distinguish create from replace. Remote upsert-oriented backends commonly
1870/// return `Upserted` because doing otherwise would require an extra read or a
1871/// backend-specific write primitive. Portable callers should treat all three
1872/// written outcomes as success and should not rely on `Inserted` or `Updated`
1873/// unless they are intentionally targeting a backend that documents those
1874/// precise outcomes.
1875#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1876pub enum PutOutcome {
1877    /// The element did not exist before and was created.
1878    Inserted,
1879    /// An element with the same identity existed and was overwritten or
1880    /// merged.
1881    Updated,
1882    /// The element was written by an upsert and the backend cannot tell
1883    /// whether it was an insert or an update.
1884    Upserted,
1885    /// The element was dropped by a dedupe policy and nothing was written.
1886    Deduped,
1887}
1888
1889impl PutOutcome {
1890    /// True unless the element was deduped away.
1891    pub fn written(self) -> bool {
1892        !matches!(self, Self::Deduped)
1893    }
1894}
1895
1896/// Counts of nodes and edges written by a bulk load. Each count reflects an
1897/// upsert applied to the backend, not distinct newly created elements.
1898#[derive(Clone, Debug, Default, Eq, PartialEq)]
1899pub struct LoadReport {
1900    pub nodes: usize,
1901    pub edges: usize,
1902}
1903
1904#[async_trait]
1905pub trait GraphStore: Send + Sync {
1906    /// Applies backend schema metadata.
1907    ///
1908    /// `GraphSchema` is a portable declaration of expected node labels, edge
1909    /// labels, fields, endpoint labels, direction, and uniqueness. The default
1910    /// implementation is a no-op for schemaless stores. Backend implementations
1911    /// may use the schema for validation, typed native tables, views, indexes,
1912    /// generated query shapes, or database-native schema definitions.
1913    ///
1914    /// Applying a schema does not imply the same enforcement guarantee on every
1915    /// backend. Callers that need portable preflight validation should call
1916    /// [`GraphSchema::validate_graph`] before writing, or use
1917    /// [`GraphStore::put_typed_graph`], which does that validation before
1918    /// applying the backend schema and writing the graph. Individual backends
1919    /// document whether they also validate each subsequent write at runtime.
1920    async fn apply_schema(&self, _schema: &GraphSchema) -> Result<()> {
1921        Ok(())
1922    }
1923
1924    /// Writes one node.
1925    ///
1926    /// The returned [`PutOutcome`] reports the most precise result the backend
1927    /// can provide. Remote upsert backends generally return
1928    /// [`PutOutcome::Upserted`] for both inserts and updates.
1929    async fn put_node(&self, node: &Node) -> Result<PutOutcome>;
1930
1931    /// Writes one edge.
1932    ///
1933    /// The returned [`PutOutcome`] reports the most precise result the backend
1934    /// can provide. Remote upsert backends generally return
1935    /// [`PutOutcome::Upserted`] for both inserts and updates.
1936    async fn put_edge(&self, edge: &Edge) -> Result<PutOutcome>;
1937
1938    async fn put_graph(&self, graph: &Graph) -> Result<LoadReport> {
1939        let mut report = LoadReport::default();
1940        for node in &graph.nodes {
1941            if self.put_node(node).await?.written() {
1942                report.nodes += 1;
1943            }
1944        }
1945        for edge in &graph.edges {
1946            if self.put_edge(edge).await?.written() {
1947                report.edges += 1;
1948            }
1949        }
1950        Ok(report)
1951    }
1952
1953    async fn put_typed_graph(&self, schema: &GraphSchema, graph: &Graph) -> Result<LoadReport> {
1954        schema.validate_graph(graph)?;
1955        self.apply_schema(schema).await?;
1956        self.put_graph(graph).await
1957    }
1958
1959    async fn get_node(&self, id: &NodeId) -> Result<Option<Node>>;
1960
1961    /// Reads multiple nodes by ID.
1962    ///
1963    /// The default implementation preserves the input order and calls
1964    /// [`GraphStore::get_node`] once per ID. Backends with a native batch-read
1965    /// path should override this to avoid per-node round trips during traversal
1966    /// and other fan-out reads.
1967    async fn get_nodes(&self, ids: &[NodeId]) -> Result<Vec<Node>> {
1968        let mut nodes = Vec::new();
1969        for id in ids {
1970            if let Some(node) = self.get_node(id).await? {
1971                nodes.push(node);
1972            }
1973        }
1974        Ok(nodes)
1975    }
1976
1977    async fn get_edges(&self, query: EdgeQuery) -> Result<Vec<Edge>>;
1978    async fn traverse(&self, traversal: Traversal) -> Result<Vec<Node>>;
1979}
1980
1981#[async_trait]
1982pub trait GraphAdminStore: GraphStore {
1983    async fn bootstrap(&self) -> Result<()> {
1984        Ok(())
1985    }
1986
1987    async fn clear(&self) -> Result<()>;
1988}
1989
1990/// A single incremental change to a graph, for delta-oriented pipelines.
1991#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1992pub enum GraphMutation {
1993    UpsertNode(Node),
1994    DeleteNode(NodeId),
1995    UpsertEdge(Edge),
1996    DeleteEdge {
1997        from: NodeId,
1998        label: Label,
1999        to: NodeId,
2000    },
2001}
2002
2003/// Incremental mutation support for stores that can delete elements.
2004///
2005/// Deletes are idempotent: removing an element that does not exist is not an
2006/// error.
2007#[async_trait]
2008pub trait GraphMutationStore: GraphStore {
2009    /// Deletes a node and all edges incident to it.
2010    async fn delete_node(&self, id: &NodeId) -> Result<()>;
2011
2012    /// Deletes the edge(s) matching `(from, label, to)`.
2013    async fn delete_edge(&self, from: &NodeId, label: &Label, to: &NodeId) -> Result<()>;
2014
2015    /// Applies mutations in order, stopping at the first error.
2016    ///
2017    /// The default implementation calls the single-mutation methods one at a
2018    /// time and is not atomic: if a later mutation fails, earlier successful
2019    /// mutations are not rolled back. Backends with transaction support should
2020    /// override this method and apply the whole slice in one transaction.
2021    async fn apply_mutations(&self, mutations: &[GraphMutation]) -> Result<()> {
2022        for mutation in mutations {
2023            match mutation {
2024                GraphMutation::UpsertNode(node) => {
2025                    self.put_node(node).await?;
2026                }
2027                GraphMutation::DeleteNode(id) => self.delete_node(id).await?,
2028                GraphMutation::UpsertEdge(edge) => {
2029                    self.put_edge(edge).await?;
2030                }
2031                GraphMutation::DeleteEdge { from, label, to } => {
2032                    self.delete_edge(from, label, to).await?
2033                }
2034            }
2035        }
2036        Ok(())
2037    }
2038}
2039
2040pub mod prelude {
2041    pub use crate::{
2042        Direction, Edge, EdgeId, EdgePolicy, EdgeQuery, EdgeType, EdgeUniqueness, Field, FieldType,
2043        Graph, GraphAdminStore, GraphBuilder, GraphMutation, GraphMutationStore, GraphSchema,
2044        GraphSchemaBuilder, GraphStore, GrustError, Label, LoadReport, Node, NodeId, NodeType,
2045        Props, PutOutcome, Result, RfcDate, Start, Step, Traversal, Value, edge_key,
2046        relationship_type, schema_identifier,
2047    };
2048
2049    #[cfg(feature = "typed-garde")]
2050    pub use crate::typed::{TypedEdge, TypedGraphBuilder, TypedNode, garde, props_from_serialize};
2051
2052    #[cfg(feature = "typed-zod-rs")]
2053    pub use crate::typed::{parse_typed_json, parse_typed_json_with, zod_rs};
2054}
2055
2056#[cfg(test)]
2057mod tests;