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