Skip to main content

golem_schema/schema/
conversion.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Golem Source License v1.1 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Runtime trait surface for converting Rust values to and from the
16//! recursive schema model.
17//!
18//! The two core traits are:
19//!
20//! - [`IntoSchema`] — emits a self-contained [`SchemaGraph`] for a type and
21//!   converts a Rust value of that type into a matching [`SchemaValue`].
22//! - [`FromSchema`] — decodes a [`SchemaValue`] back into a Rust value.
23//!
24//! Hand-written impls for the standard primitive set live below. Derived
25//! impls are generated by the `golem-schema-derive` crate, but the traits
26//! themselves can be implemented by hand for any type that needs a custom
27//! mapping.
28
29use crate::schema::graph::{SchemaGraph, SchemaTypeDef, TypedSchemaValue};
30use crate::schema::metadata::{MetadataEnvelope, TypeId};
31use crate::schema::schema_type::{SchemaType, VariantCaseType};
32use crate::schema::schema_value::{
33    BinaryValuePayload, DurationValuePayload, SchemaValue, TextValuePayload, VariantValuePayload,
34};
35use crate::schema::validation::{SchemaError, validate_graph};
36use std::collections::{BTreeMap, HashMap, HashSet};
37use std::fmt::{self, Display, Formatter};
38
39#[cfg(feature = "bigdecimal")]
40mod bigdecimal;
41#[cfg(feature = "bit_vec")]
42mod bit_vec;
43#[cfg(feature = "bytes")]
44mod bytes;
45#[cfg(feature = "chrono")]
46mod chrono;
47#[cfg(feature = "mac_address")]
48mod mac_address;
49#[cfg(feature = "nonempty_collections")]
50mod nonempty_collections;
51#[cfg(feature = "num_bigint")]
52mod num_bigint;
53#[cfg(feature = "rust_decimal")]
54mod rust_decimal;
55#[cfg(feature = "serde_json_types")]
56mod serde_json_types;
57#[cfg(feature = "url")]
58mod url;
59
60mod quantity;
61
62pub use quantity::{Quantity, QuantityUnit};
63
64// =====================================================================
65// Trait surface
66// =====================================================================
67
68/// Convert a Rust type into a self-contained schema graph and a
69/// [`SchemaValue`] tree.
70///
71/// The Rust-SDK default `type_id()` is derived from
72/// [`core::any::type_name`] by replacing `::` with `.` so the identifier
73/// matches the language-independent dotted-FQN form (`a.b.c.Xyz`, see
74/// §4.20 of the value-type spec). Generic instantiations keep the angle
75/// brackets and normalize their contents the same way:
76/// `my_crate::Container<my_crate::user::Profile>` becomes
77/// `my_crate.Container<my_crate.user.Profile>`.
78///
79/// `#[schema(named = "shared.foo")]` overrides the base name. For a
80/// generic type the derive auto-suffixes the explicit name with the
81/// resolved arguments so each instantiation gets its own definition.
82pub trait IntoSchema {
83    fn type_id() -> TypeId;
84    fn register_in(builder: &mut SchemaBuilder) -> SchemaType;
85    fn to_value(&self) -> SchemaValue;
86}
87
88/// Decode a [`SchemaValue`] back into a Rust value.
89pub trait FromSchema: Sized {
90    fn from_value(value: &SchemaValue) -> Result<Self, FromSchemaError>;
91}
92
93/// Build a self-contained graph for `T` and run well-formedness
94/// validation on the result. Returns the first reported [`SchemaError`]
95/// if the graph is malformed.
96pub fn try_into_schema_graph<T: IntoSchema + ?Sized>() -> Result<SchemaGraph, SchemaError> {
97    let mut builder = SchemaBuilder::new();
98    let root = T::register_in(&mut builder);
99    let graph = SchemaGraph {
100        defs: builder.finish(),
101        root,
102    };
103    match validate_graph(&graph) {
104        Ok(()) => Ok(graph),
105        Err(mut errors) => Err(errors.remove(0)),
106    }
107}
108
109/// Build a [`TypedSchemaValue`] — a self-contained [`SchemaGraph`] paired with
110/// the encoded [`SchemaValue`] tree — from a value whose type implements
111/// [`IntoSchema`].
112///
113/// This produces a `TypedSchemaValue` (a `SchemaGraph` + `SchemaValue`) for a
114/// Rust value carrying its own type. It is the bridge used
115/// at boundaries that still need a value carrying its own type (e.g. the public
116/// oplog / oplog-processor surface).
117pub fn try_into_typed_schema_value<T: IntoSchema + ?Sized>(
118    value: &T,
119) -> Result<TypedSchemaValue, SchemaError> {
120    let graph = try_into_schema_graph::<T>()?;
121    Ok(TypedSchemaValue::new(graph, value.to_value()))
122}
123
124/// Ergonomic `value.into_typed_schema_value()?` form of
125/// [`try_into_typed_schema_value`], mirroring the legacy
126/// `IntoValueAndType::into_value_and_type` method shape.
127pub trait IntoTypedSchemaValue: IntoSchema {
128    #[allow(clippy::wrong_self_convention)]
129    fn into_typed_schema_value(&self) -> Result<TypedSchemaValue, SchemaError>;
130}
131
132impl<T: IntoSchema + ?Sized> IntoTypedSchemaValue for T {
133    fn into_typed_schema_value(&self) -> Result<TypedSchemaValue, SchemaError> {
134        try_into_typed_schema_value(self)
135    }
136}
137
138/// Errors produced by [`merge_agent_graphs`].
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum MergeError {
141    /// Two input graphs declare the same [`TypeId`] with divergent
142    /// definition bodies. The agent's shared name pool requires every
143    /// `TypeId` to resolve to exactly one body; same id with the same
144    /// body is fine, same id with different bodies is rejected.
145    ConflictingDefinitions {
146        id: TypeId,
147        first_name: Option<String>,
148        second_name: Option<String>,
149    },
150}
151
152impl Display for MergeError {
153    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
154        match self {
155            MergeError::ConflictingDefinitions {
156                id,
157                first_name,
158                second_name,
159            } => write!(
160                f,
161                "conflicting definitions for TypeId `{id}` while merging agent graphs \
162                 (first name = {first_name:?}, second name = {second_name:?})"
163            ),
164        }
165    }
166}
167
168impl std::error::Error for MergeError {}
169
170/// Merge the named definitions from any number of per-root
171/// [`SchemaGraph`]s into a single agent-level graph.
172///
173/// Intended for SDK / derive-crate codegen that produces one
174/// [`crate::schema::agent::AgentTypeSchema`] per agent: each constructor
175/// input, method input, and method output type is converted via
176/// [`try_into_schema_graph`] or [`IntoSchema::register_in`], then all
177/// resulting graphs are merged here to produce the agent's shared
178/// [`crate::schema::agent::AgentTypeSchema::schema`] field (see §4.22).
179///
180/// Behaviour:
181///
182/// - The merged graph carries a sentinel root via
183///   [`SchemaGraph::empty`]; only its `defs` are meaningful.
184///   **Input graph roots are ignored.** Callers must retain the
185///   original [`SchemaType`] roots themselves and install them into
186///   the agent's constructor/method input/output slots.
187/// - Definitions are deduplicated by [`TypeId`]: identical bodies under
188///   the same id are merged into a single entry.
189/// - Same id with **divergent** bodies fails with
190///   [`MergeError::ConflictingDefinitions`]. Conflict detection uses
191///   exact [`SchemaType`] equality (including metadata).
192///   [`SchemaTypeDef::name`] is display-only and does not participate
193///   in conflict detection; when duplicates have identical bodies, the
194///   first-seen definition (including its `name`) is retained.
195/// - The order of definitions in the result follows first-seen
196///   insertion order across the input iterator.
197///
198/// Call this **once per carrier**: separately for the parent
199/// [`crate::schema::agent::AgentTypeSchema::schema`] and separately for
200/// each [`crate::schema::agent::AgentDependencySchema::schema`].
201/// Dependencies own independent graphs (§4.22) — do not merge their
202/// definitions into the parent agent's graph.
203pub fn merge_agent_graphs(
204    graphs: impl IntoIterator<Item = SchemaGraph>,
205) -> Result<SchemaGraph, MergeError> {
206    let mut merged: Vec<SchemaTypeDef> = Vec::new();
207
208    for graph in graphs {
209        for def in graph.defs {
210            if let Some(existing) = merged.iter().find(|d| d.id == def.id) {
211                if existing.body != def.body {
212                    return Err(MergeError::ConflictingDefinitions {
213                        id: def.id,
214                        first_name: existing.name.clone(),
215                        second_name: def.name,
216                    });
217                }
218                // identical body — skip, first-seen wins
219            } else {
220                merged.push(def);
221            }
222        }
223    }
224
225    Ok(SchemaGraph {
226        defs: merged,
227        root: SchemaGraph::empty().root,
228    })
229}
230
231// =====================================================================
232// Builder
233// =====================================================================
234
235/// Mutable working set used while a graph is being built up.
236///
237/// Derived `register_in` bodies call [`SchemaBuilder::reserve`] before
238/// recursing into inner fields so back-references through
239/// [`SchemaType::Ref`] can close without infinite recursion. Once the
240/// body is fully constructed they call [`SchemaBuilder::commit`] to
241/// promote the reservation to a real definition.
242#[derive(Default)]
243pub struct SchemaBuilder {
244    defs: Vec<SchemaTypeDef>,
245    reserved: HashSet<TypeId>,
246}
247
248impl SchemaBuilder {
249    pub fn new() -> Self {
250        Self::default()
251    }
252
253    /// `true` when `id` is already reserved or committed in this builder.
254    pub fn is_registered(&self, id: &TypeId) -> bool {
255        self.reserved.contains(id) || self.defs.iter().any(|d| &d.id == id)
256    }
257
258    /// Reserve a slot for `id` so recursive references close without
259    /// infinite recursion. Must be followed by a matching
260    /// [`Self::commit`] call once the body is built.
261    pub fn reserve(&mut self, id: TypeId) {
262        self.reserved.insert(id);
263    }
264
265    /// Promote a reservation to a real definition.
266    ///
267    /// `metadata` is attached to the body's [`MetadataEnvelope`] (every
268    /// [`SchemaType`] node carries metadata directly; the def no longer has
269    /// a separate slot for it).
270    pub fn commit(
271        &mut self,
272        id: TypeId,
273        name: Option<String>,
274        metadata: MetadataEnvelope,
275        body: SchemaType,
276    ) {
277        self.reserved.remove(&id);
278        let body = body.with_metadata(metadata);
279        // De-duplicate: if a definition with this id already exists
280        // (because of a parallel registration in a sibling subtree) the
281        // first one wins; later registrations are idempotent overwrites.
282        if let Some(existing) = self.defs.iter_mut().find(|d| d.id == id) {
283            *existing = SchemaTypeDef { id, name, body };
284        } else {
285            self.defs.push(SchemaTypeDef { id, name, body });
286        }
287    }
288
289    /// Consume the builder and return the accumulated definitions.
290    pub fn finish(self) -> Vec<SchemaTypeDef> {
291        self.defs
292    }
293
294    /// Build a [`SchemaGraph`] using `root` as the entry point.
295    pub fn into_graph(self, root: SchemaType) -> SchemaGraph {
296        SchemaGraph {
297            defs: self.finish(),
298            root,
299        }
300    }
301}
302
303// =====================================================================
304// TypeId helpers — used by generated code
305// =====================================================================
306
307/// Normalize a Rust path-form name into the dotted FQN used by [`TypeId`].
308///
309/// Every `::` becomes `.`; the inside of any `<…>` argument list is
310/// normalized recursively by the same rule (because `::` only occurs in
311/// path-like positions, a plain string replacement suffices). See §4.20.
312pub fn normalize_type_path(raw: &str) -> String {
313    raw.replace("::", ".")
314}
315
316/// Build a [`TypeId`] for a generic instantiation. `base` is the
317/// already-normalized base name (e.g., `"my_crate.Container"`) and
318/// `args` are the resolved argument [`TypeId`]s in declaration order.
319///
320/// `Container<u32>` → `my_crate.Container<u32>`.
321/// `Container<u32, String>` → `my_crate.Container<u32, alloc.string.String>`.
322pub fn type_id_with_args(base: &str, args: &[TypeId]) -> TypeId {
323    if args.is_empty() {
324        TypeId::new(base.to_string())
325    } else {
326        let mut s = String::with_capacity(base.len() + 16);
327        s.push_str(base);
328        s.push('<');
329        for (i, a) in args.iter().enumerate() {
330            if i > 0 {
331                s.push_str(", ");
332            }
333            s.push_str(a.as_str());
334        }
335        s.push('>');
336        TypeId::new(s)
337    }
338}
339
340/// Default [`TypeId`] derivation from a `core::any::type_name::<Self>()`
341/// output. Equivalent to `normalize_type_path` but exposed under a
342/// generated-code-friendly name.
343pub fn default_type_id_from(raw: &str) -> TypeId {
344    TypeId::new(normalize_type_path(raw))
345}
346
347// =====================================================================
348// Errors
349// =====================================================================
350
351/// Errors produced by [`FromSchema::from_value`].
352#[derive(Clone, Debug, PartialEq, Eq)]
353pub enum FromSchemaError {
354    /// The expected value shape does not match what was provided.
355    ShapeMismatch {
356        expected: String,
357        actual: String,
358        context: String,
359    },
360    /// A variant or enum case index is out of range.
361    OutOfRange {
362        index: u32,
363        len: u32,
364        context: String,
365    },
366    /// A union tag was not matched by any branch.
367    UnknownUnionTag(String),
368    /// Free-form error message produced by generated decoders.
369    Custom(String),
370}
371
372/// Backwards-compatible alias kept for codegen and consumers.
373pub type DecodeError = FromSchemaError;
374
375impl FromSchemaError {
376    pub fn shape_mismatch(
377        expected: impl Into<String>,
378        actual: impl Into<String>,
379        context: impl Into<String>,
380    ) -> Self {
381        Self::ShapeMismatch {
382            expected: expected.into(),
383            actual: actual.into(),
384            context: context.into(),
385        }
386    }
387
388    pub fn out_of_range(index: u32, len: u32, context: impl Into<String>) -> Self {
389        Self::OutOfRange {
390            index,
391            len,
392            context: context.into(),
393        }
394    }
395
396    pub fn custom(msg: impl Into<String>) -> Self {
397        Self::Custom(msg.into())
398    }
399}
400
401impl Display for FromSchemaError {
402    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
403        match self {
404            FromSchemaError::ShapeMismatch {
405                expected,
406                actual,
407                context,
408            } => write!(
409                f,
410                "shape mismatch in {context}: expected {expected}, got {actual}"
411            ),
412            FromSchemaError::OutOfRange {
413                index,
414                len,
415                context,
416            } => write!(f, "index {index} out of range (len {len}) in {context}"),
417            FromSchemaError::UnknownUnionTag(tag) => write!(f, "unknown union tag `{tag}`"),
418            FromSchemaError::Custom(msg) => f.write_str(msg),
419        }
420    }
421}
422
423impl std::error::Error for FromSchemaError {}
424
425/// Stringify the kind of a [`SchemaValue`] for error messages.
426pub fn value_kind(v: &SchemaValue) -> &'static str {
427    match v {
428        SchemaValue::Bool(_) => "bool",
429        SchemaValue::S8(_) => "s8",
430        SchemaValue::S16(_) => "s16",
431        SchemaValue::S32(_) => "s32",
432        SchemaValue::S64(_) => "s64",
433        SchemaValue::U8(_) => "u8",
434        SchemaValue::U16(_) => "u16",
435        SchemaValue::U32(_) => "u32",
436        SchemaValue::U64(_) => "u64",
437        SchemaValue::F32(_) => "f32",
438        SchemaValue::F64(_) => "f64",
439        SchemaValue::Char(_) => "char",
440        SchemaValue::String(_) => "string",
441        SchemaValue::Record { .. } => "record",
442        SchemaValue::Variant(_) => "variant",
443        SchemaValue::Enum { .. } => "enum",
444        SchemaValue::Flags { .. } => "flags",
445        SchemaValue::Tuple { .. } => "tuple",
446        SchemaValue::List { .. } => "list",
447        SchemaValue::FixedList { .. } => "fixed-list",
448        SchemaValue::Map { .. } => "map",
449        SchemaValue::Option { .. } => "option",
450        SchemaValue::Result(_) => "result",
451        SchemaValue::Text(_) => "text",
452        SchemaValue::Binary(_) => "binary",
453        SchemaValue::Path { .. } => "path",
454        SchemaValue::Url { .. } => "url",
455        SchemaValue::Uuid(_) => "uuid",
456        SchemaValue::Datetime { .. } => "datetime",
457        SchemaValue::Duration(_) => "duration",
458        SchemaValue::Quantity(_) => "quantity",
459        SchemaValue::Union(_) => "union",
460        SchemaValue::Secret(_) => "secret",
461        SchemaValue::QuotaToken(_) => "quota-token",
462        SchemaValue::Stream(_) => "stream",
463        SchemaValue::PermissionCard(_) => "permission-card",
464    }
465}
466
467// =====================================================================
468// Rich-scalar codec helpers (used by codegen for #[schema(text(...))]
469// etc.)
470// =====================================================================
471
472pub fn text_to_value(body: String, language: Option<String>) -> SchemaValue {
473    SchemaValue::Text(TextValuePayload {
474        text: body,
475        language,
476    })
477}
478
479pub fn text_from_value(v: &SchemaValue, context: &'static str) -> Result<String, FromSchemaError> {
480    match v {
481        SchemaValue::Text(p) => Ok(p.text.clone()),
482        other => Err(FromSchemaError::shape_mismatch(
483            "text",
484            value_kind(other),
485            context,
486        )),
487    }
488}
489
490pub fn binary_to_value(bytes: Vec<u8>, mime_type: Option<String>) -> SchemaValue {
491    SchemaValue::Binary(BinaryValuePayload { bytes, mime_type })
492}
493
494pub fn binary_from_value(
495    v: &SchemaValue,
496    context: &'static str,
497) -> Result<Vec<u8>, FromSchemaError> {
498    match v {
499        SchemaValue::Binary(p) => Ok(p.bytes.clone()),
500        other => Err(FromSchemaError::shape_mismatch(
501            "binary",
502            value_kind(other),
503            context,
504        )),
505    }
506}
507
508pub fn path_to_value(path: String) -> SchemaValue {
509    SchemaValue::Path { path }
510}
511
512pub fn path_from_value(v: &SchemaValue, context: &'static str) -> Result<String, FromSchemaError> {
513    match v {
514        SchemaValue::Path { path } => Ok(path.clone()),
515        other => Err(FromSchemaError::shape_mismatch(
516            "path",
517            value_kind(other),
518            context,
519        )),
520    }
521}
522
523pub fn url_to_value(url: String) -> SchemaValue {
524    SchemaValue::Url { url }
525}
526
527pub fn url_from_value(v: &SchemaValue, context: &'static str) -> Result<String, FromSchemaError> {
528    match v {
529        SchemaValue::Url { url } => Ok(url.clone()),
530        other => Err(FromSchemaError::shape_mismatch(
531            "url",
532            value_kind(other),
533            context,
534        )),
535    }
536}
537
538// =====================================================================
539// Primitive impls
540// =====================================================================
541
542macro_rules! impl_primitive {
543    ($ty:ty, $type_id:literal, $schema_ty:expr, $variant:ident) => {
544        impl IntoSchema for $ty {
545            fn type_id() -> TypeId {
546                TypeId::new($type_id)
547            }
548            fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
549                $schema_ty
550            }
551            fn to_value(&self) -> SchemaValue {
552                SchemaValue::$variant(*self)
553            }
554        }
555        impl FromSchema for $ty {
556            fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
557                match v {
558                    SchemaValue::$variant(x) => Ok(*x),
559                    other => Err(FromSchemaError::shape_mismatch(
560                        stringify!($variant),
561                        value_kind(other),
562                        stringify!($ty),
563                    )),
564                }
565            }
566        }
567    };
568}
569
570impl_primitive!(bool, "bool", SchemaType::bool(), Bool);
571impl_primitive!(i8, "i8", SchemaType::s8(), S8);
572impl_primitive!(i16, "i16", SchemaType::s16(), S16);
573impl_primitive!(i32, "i32", SchemaType::s32(), S32);
574impl_primitive!(i64, "i64", SchemaType::s64(), S64);
575impl_primitive!(u8, "u8", SchemaType::u8(), U8);
576impl_primitive!(u16, "u16", SchemaType::u16(), U16);
577impl_primitive!(u32, "u32", SchemaType::u32(), U32);
578impl_primitive!(u64, "u64", SchemaType::u64(), U64);
579impl_primitive!(f32, "f32", SchemaType::f32(), F32);
580impl_primitive!(f64, "f64", SchemaType::f64(), F64);
581impl_primitive!(char, "char", SchemaType::char(), Char);
582
583impl IntoSchema for String {
584    fn type_id() -> TypeId {
585        TypeId::new("alloc.string.String")
586    }
587    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
588        SchemaType::string()
589    }
590    fn to_value(&self) -> SchemaValue {
591        SchemaValue::String(self.clone())
592    }
593}
594
595impl FromSchema for String {
596    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
597        match v {
598            SchemaValue::String(s) => Ok(s.clone()),
599            other => Err(FromSchemaError::shape_mismatch(
600                "string",
601                value_kind(other),
602                "String",
603            )),
604        }
605    }
606}
607
608/// `str` schema is identical to [`String`], so a `&str` produces the same
609/// schema graph and value as an owned `String`. This makes string literals
610/// usable directly as agent parameters without an explicit `.to_string()`.
611impl IntoSchema for str {
612    fn type_id() -> TypeId {
613        TypeId::new("alloc.string.String")
614    }
615    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
616        SchemaType::string()
617    }
618    fn to_value(&self) -> SchemaValue {
619        SchemaValue::String(self.to_string())
620    }
621}
622
623/// Forward [`IntoSchema`] through shared references so that `&T` values (most
624/// commonly `&str` string literals) can be used wherever an `IntoSchema` value
625/// is expected.
626impl<T: IntoSchema + ?Sized> IntoSchema for &T {
627    fn type_id() -> TypeId {
628        T::type_id()
629    }
630    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
631        T::register_in(b)
632    }
633    fn to_value(&self) -> SchemaValue {
634        (**self).to_value()
635    }
636}
637
638// =====================================================================
639// Option / Vec / Map / Result / Box / Rc / Arc / tuples
640// =====================================================================
641
642impl<T: IntoSchema + ?Sized> IntoSchema for Box<T> {
643    fn type_id() -> TypeId {
644        T::type_id()
645    }
646    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
647        T::register_in(b)
648    }
649    fn to_value(&self) -> SchemaValue {
650        (**self).to_value()
651    }
652}
653
654impl<T: FromSchema> FromSchema for Box<T> {
655    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
656        Ok(Box::new(T::from_value(v)?))
657    }
658}
659
660impl<T: IntoSchema + ?Sized> IntoSchema for std::rc::Rc<T> {
661    fn type_id() -> TypeId {
662        T::type_id()
663    }
664    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
665        T::register_in(b)
666    }
667    fn to_value(&self) -> SchemaValue {
668        (**self).to_value()
669    }
670}
671
672impl<T: FromSchema> FromSchema for std::rc::Rc<T> {
673    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
674        Ok(std::rc::Rc::new(T::from_value(v)?))
675    }
676}
677
678impl<T: IntoSchema + ?Sized> IntoSchema for std::sync::Arc<T> {
679    fn type_id() -> TypeId {
680        T::type_id()
681    }
682    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
683        T::register_in(b)
684    }
685    fn to_value(&self) -> SchemaValue {
686        (**self).to_value()
687    }
688}
689
690impl<T: FromSchema> FromSchema for std::sync::Arc<T> {
691    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
692        Ok(std::sync::Arc::new(T::from_value(v)?))
693    }
694}
695
696impl<T: IntoSchema> IntoSchema for Option<T> {
697    fn type_id() -> TypeId {
698        type_id_with_args("core.option.Option", &[T::type_id()])
699    }
700    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
701        SchemaType::option(T::register_in(b))
702    }
703    fn to_value(&self) -> SchemaValue {
704        SchemaValue::Option {
705            inner: self.as_ref().map(|x| Box::new(x.to_value())),
706        }
707    }
708}
709
710impl<T: FromSchema> FromSchema for Option<T> {
711    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
712        match v {
713            SchemaValue::Option { inner: Some(x) } => Ok(Some(T::from_value(x)?)),
714            SchemaValue::Option { inner: None } => Ok(None),
715            other => Err(FromSchemaError::shape_mismatch(
716                "option",
717                value_kind(other),
718                "Option",
719            )),
720        }
721    }
722}
723
724impl<T: IntoSchema> IntoSchema for Vec<T> {
725    fn type_id() -> TypeId {
726        type_id_with_args("alloc.vec.Vec", &[T::type_id()])
727    }
728    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
729        SchemaType::list(T::register_in(b))
730    }
731    fn to_value(&self) -> SchemaValue {
732        SchemaValue::List {
733            elements: self.iter().map(|x| x.to_value()).collect(),
734        }
735    }
736}
737
738impl<T: FromSchema> FromSchema for Vec<T> {
739    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
740        match v {
741            SchemaValue::List { elements } => elements.iter().map(T::from_value).collect(),
742            other => Err(FromSchemaError::shape_mismatch(
743                "list",
744                value_kind(other),
745                "Vec",
746            )),
747        }
748    }
749}
750
751impl<K: IntoSchema, V: IntoSchema> IntoSchema for HashMap<K, V> {
752    fn type_id() -> TypeId {
753        type_id_with_args("std.collections.HashMap", &[K::type_id(), V::type_id()])
754    }
755    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
756        SchemaType::map(K::register_in(b), V::register_in(b))
757    }
758    fn to_value(&self) -> SchemaValue {
759        SchemaValue::Map {
760            entries: self
761                .iter()
762                .map(|(k, v)| (k.to_value(), v.to_value()))
763                .collect(),
764        }
765    }
766}
767
768impl<K, V> FromSchema for HashMap<K, V>
769where
770    K: FromSchema + std::hash::Hash + Eq,
771    V: FromSchema,
772{
773    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
774        match v {
775            SchemaValue::Map { entries } => entries
776                .iter()
777                .map(|(k, v)| Ok((K::from_value(k)?, V::from_value(v)?)))
778                .collect(),
779            other => Err(FromSchemaError::shape_mismatch(
780                "map",
781                value_kind(other),
782                "HashMap",
783            )),
784        }
785    }
786}
787
788impl<K: IntoSchema, V: IntoSchema> IntoSchema for BTreeMap<K, V> {
789    fn type_id() -> TypeId {
790        type_id_with_args("alloc.collections.BTreeMap", &[K::type_id(), V::type_id()])
791    }
792    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
793        SchemaType::map(K::register_in(b), V::register_in(b))
794    }
795    fn to_value(&self) -> SchemaValue {
796        SchemaValue::Map {
797            entries: self
798                .iter()
799                .map(|(k, v)| (k.to_value(), v.to_value()))
800                .collect(),
801        }
802    }
803}
804
805impl<K, V> FromSchema for BTreeMap<K, V>
806where
807    K: FromSchema + Ord,
808    V: FromSchema,
809{
810    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
811        match v {
812            SchemaValue::Map { entries } => entries
813                .iter()
814                .map(|(k, v)| Ok((K::from_value(k)?, V::from_value(v)?)))
815                .collect(),
816            other => Err(FromSchemaError::shape_mismatch(
817                "map",
818                value_kind(other),
819                "BTreeMap",
820            )),
821        }
822    }
823}
824
825// =====================================================================
826// Tuples (1..12)
827// =====================================================================
828
829macro_rules! impl_tuple {
830    ( $name:literal => $( $idx:tt : $t:ident ),+ ) => {
831        impl< $( $t : IntoSchema ),+ > IntoSchema for ( $( $t, )+ ) {
832            fn type_id() -> TypeId {
833                type_id_with_args($name, &[ $( <$t as IntoSchema>::type_id() ),+ ])
834            }
835            fn register_in(b: &mut SchemaBuilder) -> SchemaType {
836                SchemaType::tuple(vec![ $( <$t as IntoSchema>::register_in(b) ),+ ])
837            }
838            fn to_value(&self) -> SchemaValue {
839                SchemaValue::Tuple {
840                    elements: vec![ $( IntoSchema::to_value(&self.$idx) ),+ ],
841                }
842            }
843        }
844        impl< $( $t : FromSchema ),+ > FromSchema for ( $( $t, )+ ) {
845            fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
846                match v {
847                    SchemaValue::Tuple { elements } => {
848                        let expected = [ $( stringify!($t) ),+ ].len();
849                        if elements.len() != expected {
850                            return Err(FromSchemaError::custom(format!(
851                                "tuple length mismatch: expected {expected}, got {}",
852                                elements.len()
853                            )));
854                        }
855                        Ok(( $( <$t as FromSchema>::from_value(&elements[$idx])? , )+ ))
856                    }
857                    other => Err(FromSchemaError::shape_mismatch(
858                        "tuple",
859                        value_kind(other),
860                        "tuple",
861                    )),
862                }
863            }
864        }
865    };
866}
867
868impl_tuple!("core.tuple.Tuple1" => 0: T0);
869impl_tuple!("core.tuple.Tuple2" => 0: T0, 1: T1);
870impl_tuple!("core.tuple.Tuple3" => 0: T0, 1: T1, 2: T2);
871impl_tuple!("core.tuple.Tuple4" => 0: T0, 1: T1, 2: T2, 3: T3);
872impl_tuple!("core.tuple.Tuple5" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4);
873impl_tuple!("core.tuple.Tuple6" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4, 5: T5);
874impl_tuple!("core.tuple.Tuple7" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4, 5: T5, 6: T6);
875impl_tuple!("core.tuple.Tuple8" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4, 5: T5, 6: T6, 7: T7);
876
877impl IntoSchema for () {
878    fn type_id() -> TypeId {
879        TypeId::new("core.tuple.Unit")
880    }
881    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
882        SchemaType::tuple(vec![])
883    }
884    fn to_value(&self) -> SchemaValue {
885        SchemaValue::Tuple { elements: vec![] }
886    }
887}
888
889impl FromSchema for () {
890    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
891        match v {
892            SchemaValue::Tuple { elements } if elements.is_empty() => Ok(()),
893            other => Err(FromSchemaError::shape_mismatch(
894                "unit tuple",
895                value_kind(other),
896                "()",
897            )),
898        }
899    }
900}
901
902// =====================================================================
903// Result<T, E>
904// =====================================================================
905
906/// `true` if `T` is the unit type `()`, whose ok/err branch carries no payload
907/// (`result<_, _>`) and is represented by `None` in the schema and value model.
908fn is_unit_type<T: IntoSchema>() -> bool {
909    T::type_id() == <() as IntoSchema>::type_id()
910}
911
912impl<T: IntoSchema, E: IntoSchema> IntoSchema for Result<T, E> {
913    fn type_id() -> TypeId {
914        type_id_with_args("core.result.Result", &[T::type_id(), E::type_id()])
915    }
916    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
917        let ok = if is_unit_type::<T>() {
918            None
919        } else {
920            Some(Box::new(T::register_in(b)))
921        };
922        let err = if is_unit_type::<E>() {
923            None
924        } else {
925            Some(Box::new(E::register_in(b)))
926        };
927        SchemaType::result(crate::schema::schema_type::ResultSpec { ok, err })
928    }
929    fn to_value(&self) -> SchemaValue {
930        use crate::schema::schema_value::ResultValuePayload;
931        match self {
932            Ok(x) => SchemaValue::Result(ResultValuePayload::Ok {
933                value: if is_unit_type::<T>() {
934                    None
935                } else {
936                    Some(Box::new(x.to_value()))
937                },
938            }),
939            Err(e) => SchemaValue::Result(ResultValuePayload::Err {
940                value: if is_unit_type::<E>() {
941                    None
942                } else {
943                    Some(Box::new(e.to_value()))
944                },
945            }),
946        }
947    }
948}
949
950impl<T: FromSchema, E: FromSchema> FromSchema for Result<T, E> {
951    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
952        use crate::schema::schema_value::ResultValuePayload;
953        let unit = SchemaValue::Tuple { elements: vec![] };
954        match v {
955            SchemaValue::Result(ResultValuePayload::Ok { value }) => {
956                let inner = value.as_deref().unwrap_or(&unit);
957                Ok(Ok(T::from_value(inner)?))
958            }
959            SchemaValue::Result(ResultValuePayload::Err { value }) => {
960                let inner = value.as_deref().unwrap_or(&unit);
961                Ok(Err(E::from_value(inner)?))
962            }
963            other => Err(FromSchemaError::shape_mismatch(
964                "result",
965                value_kind(other),
966                "Result",
967            )),
968        }
969    }
970}
971
972// =====================================================================
973// chrono::DateTime / std::time
974// =====================================================================
975
976impl IntoSchema for ::chrono::DateTime<::chrono::Utc> {
977    fn type_id() -> TypeId {
978        TypeId::new("chrono.DateTime<chrono.Utc>")
979    }
980    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
981        SchemaType::datetime()
982    }
983    fn to_value(&self) -> SchemaValue {
984        SchemaValue::Datetime { value: *self }
985    }
986}
987
988impl FromSchema for ::chrono::DateTime<::chrono::Utc> {
989    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
990        match v {
991            SchemaValue::Datetime { value } => Ok(*value),
992            other => Err(FromSchemaError::shape_mismatch(
993                "datetime",
994                value_kind(other),
995                "DateTime<Utc>",
996            )),
997        }
998    }
999}
1000
1001impl IntoSchema for std::time::Duration {
1002    fn type_id() -> TypeId {
1003        TypeId::new("core.time.Duration")
1004    }
1005    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
1006        SchemaType::duration()
1007    }
1008    fn to_value(&self) -> SchemaValue {
1009        let nanos = self.as_nanos();
1010        let clamped: i64 = if nanos > i64::MAX as u128 {
1011            i64::MAX
1012        } else {
1013            nanos as i64
1014        };
1015        SchemaValue::Duration(DurationValuePayload {
1016            nanoseconds: clamped,
1017        })
1018    }
1019}
1020
1021impl FromSchema for std::time::Duration {
1022    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1023        match v {
1024            SchemaValue::Duration(p) => {
1025                if p.nanoseconds < 0 {
1026                    Err(FromSchemaError::custom(
1027                        "std::time::Duration cannot decode a negative duration",
1028                    ))
1029                } else {
1030                    Ok(std::time::Duration::from_nanos(p.nanoseconds as u64))
1031                }
1032            }
1033            other => Err(FromSchemaError::shape_mismatch(
1034                "duration",
1035                value_kind(other),
1036                "Duration",
1037            )),
1038        }
1039    }
1040}
1041
1042// A `std::path::PathBuf` is modelled as the rich `Path` schema type. Only
1043// UTF-8 paths are supported because `SchemaValue::Path` is string-backed.
1044impl IntoSchema for std::path::PathBuf {
1045    fn type_id() -> TypeId {
1046        TypeId::new("std.path.PathBuf")
1047    }
1048    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
1049        use crate::schema::schema_type::{PathDirection, PathKind, PathSpec};
1050        SchemaType::path(PathSpec {
1051            direction: PathDirection::InOut,
1052            kind: PathKind::Any,
1053            allowed_mime_types: None,
1054            allowed_extensions: None,
1055        })
1056    }
1057    fn to_value(&self) -> SchemaValue {
1058        match self.to_str() {
1059            Some(s) => path_to_value(s.to_string()),
1060            None => path_to_value(self.to_string_lossy().into_owned()),
1061        }
1062    }
1063}
1064
1065impl FromSchema for std::path::PathBuf {
1066    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1067        Ok(std::path::PathBuf::from(path_from_value(v, "PathBuf")?))
1068    }
1069}
1070
1071impl IntoSchema for uuid::Uuid {
1072    fn type_id() -> TypeId {
1073        TypeId::new("uuid.Uuid")
1074    }
1075    fn register_in(_builder: &mut SchemaBuilder) -> SchemaType {
1076        SchemaType::uuid()
1077    }
1078    fn to_value(&self) -> SchemaValue {
1079        SchemaValue::Uuid(*self)
1080    }
1081}
1082
1083impl FromSchema for uuid::Uuid {
1084    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1085        match v {
1086            SchemaValue::Uuid(value) => Ok(*value),
1087            other => Err(FromSchemaError::shape_mismatch(
1088                "uuid",
1089                value_kind(other),
1090                "Uuid",
1091            )),
1092        }
1093    }
1094}
1095
1096// `usize` is modelled as a `u64` (the schema model has no platform-width
1097// integer; oplog payloads never need values above `u64::MAX`).
1098impl IntoSchema for usize {
1099    fn type_id() -> TypeId {
1100        TypeId::new("usize")
1101    }
1102    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
1103        SchemaType::u64()
1104    }
1105    fn to_value(&self) -> SchemaValue {
1106        SchemaValue::U64(*self as u64)
1107    }
1108}
1109
1110impl FromSchema for usize {
1111    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1112        match v {
1113            SchemaValue::U64(x) => Ok(*x as usize),
1114            other => Err(FromSchemaError::shape_mismatch(
1115                "u64",
1116                value_kind(other),
1117                "usize",
1118            )),
1119        }
1120    }
1121}
1122
1123// `std::ops::Bound<T>` is modelled as a 3-case variant
1124// `included(T) | excluded(T) | unbounded`.
1125impl<T: IntoSchema> IntoSchema for std::ops::Bound<T> {
1126    fn type_id() -> TypeId {
1127        type_id_with_args("core.ops.range.Bound", &[T::type_id()])
1128    }
1129    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
1130        let inner = T::register_in(b);
1131        SchemaType::variant(vec![
1132            VariantCaseType {
1133                name: "included".to_string(),
1134                payload: Some(inner.clone()),
1135                metadata: MetadataEnvelope::default(),
1136            },
1137            VariantCaseType {
1138                name: "excluded".to_string(),
1139                payload: Some(inner),
1140                metadata: MetadataEnvelope::default(),
1141            },
1142            VariantCaseType {
1143                name: "unbounded".to_string(),
1144                payload: None,
1145                metadata: MetadataEnvelope::default(),
1146            },
1147        ])
1148    }
1149    fn to_value(&self) -> SchemaValue {
1150        match self {
1151            std::ops::Bound::Included(t) => SchemaValue::Variant(VariantValuePayload {
1152                case: 0,
1153                payload: Some(Box::new(t.to_value())),
1154            }),
1155            std::ops::Bound::Excluded(t) => SchemaValue::Variant(VariantValuePayload {
1156                case: 1,
1157                payload: Some(Box::new(t.to_value())),
1158            }),
1159            std::ops::Bound::Unbounded => SchemaValue::Variant(VariantValuePayload {
1160                case: 2,
1161                payload: None,
1162            }),
1163        }
1164    }
1165}
1166
1167impl<T: FromSchema> FromSchema for std::ops::Bound<T> {
1168    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1169        match v {
1170            SchemaValue::Variant(VariantValuePayload { case, payload }) => match case {
1171                0 => {
1172                    let p = payload.as_ref().ok_or_else(|| {
1173                        FromSchemaError::custom("Bound::Included requires a payload")
1174                    })?;
1175                    Ok(std::ops::Bound::Included(T::from_value(p)?))
1176                }
1177                1 => {
1178                    let p = payload.as_ref().ok_or_else(|| {
1179                        FromSchemaError::custom("Bound::Excluded requires a payload")
1180                    })?;
1181                    Ok(std::ops::Bound::Excluded(T::from_value(p)?))
1182                }
1183                2 => Ok(std::ops::Bound::Unbounded),
1184                other => Err(FromSchemaError::out_of_range(*other, 3, "Bound")),
1185            },
1186            other => Err(FromSchemaError::shape_mismatch(
1187                "variant",
1188                value_kind(other),
1189                "Bound",
1190            )),
1191        }
1192    }
1193}
1194
1195// =====================================================================
1196// Tests
1197// =====================================================================
1198
1199#[cfg(test)]
1200mod result_unit_tests {
1201    use super::*;
1202    use crate::schema::schema_value::ResultValuePayload;
1203    use test_r::test;
1204
1205    #[test]
1206    fn ok_unit_encodes_as_none() {
1207        let v: Result<(), String> = Ok(());
1208        assert_eq!(
1209            v.to_value(),
1210            SchemaValue::Result(ResultValuePayload::Ok { value: None })
1211        );
1212    }
1213
1214    #[test]
1215    fn err_unit_encodes_as_none() {
1216        let v: Result<u32, ()> = Err(());
1217        assert_eq!(
1218            v.to_value(),
1219            SchemaValue::Result(ResultValuePayload::Err { value: None })
1220        );
1221    }
1222
1223    #[test]
1224    fn non_unit_branches_still_carry_payload() {
1225        let ok: Result<u32, String> = Ok(7);
1226        assert_eq!(
1227            ok.to_value(),
1228            SchemaValue::Result(ResultValuePayload::Ok {
1229                value: Some(Box::new(7u32.to_value()))
1230            })
1231        );
1232        let err: Result<u32, String> = Err("boom".to_string());
1233        assert_eq!(
1234            err.to_value(),
1235            SchemaValue::Result(ResultValuePayload::Err {
1236                value: Some(Box::new("boom".to_string().to_value()))
1237            })
1238        );
1239    }
1240
1241    #[test]
1242    fn unit_branch_schema_is_none() {
1243        let mut b = SchemaBuilder::new();
1244        let ty = <Result<(), String> as IntoSchema>::register_in(&mut b);
1245        match ty {
1246            SchemaType::Result { spec, .. } => {
1247                assert!(spec.ok.is_none(), "ok branch should be None for unit");
1248                assert!(spec.err.is_some(), "err branch should carry string");
1249            }
1250            other => panic!("expected result schema, got {other:?}"),
1251        }
1252    }
1253
1254    #[test]
1255    fn ok_unit_roundtrips() {
1256        let v: Result<(), String> = Ok(());
1257        let decoded = <Result<(), String> as FromSchema>::from_value(&v.to_value()).unwrap();
1258        assert_eq!(decoded, Ok(()));
1259    }
1260
1261    #[test]
1262    fn err_unit_roundtrips() {
1263        let v: Result<u32, ()> = Err(());
1264        let decoded = <Result<u32, ()> as FromSchema>::from_value(&v.to_value()).unwrap();
1265        assert_eq!(decoded, Err(()));
1266    }
1267}
1268
1269#[cfg(test)]
1270mod merge_tests {
1271    use super::*;
1272    use test_r::test;
1273
1274    fn def(id: &str, body: SchemaType) -> SchemaTypeDef {
1275        SchemaTypeDef {
1276            id: TypeId::new(id),
1277            name: None,
1278            body,
1279        }
1280    }
1281
1282    #[test]
1283    fn empty_input_produces_empty_merged_graph() {
1284        let merged = merge_agent_graphs(std::iter::empty()).unwrap();
1285        assert!(merged.defs.is_empty());
1286        // root is the sentinel from SchemaGraph::empty()
1287        assert_eq!(merged.root, SchemaGraph::empty().root);
1288    }
1289
1290    #[test]
1291    fn defs_are_deduplicated_by_type_id_when_bodies_match() {
1292        let g1 = SchemaGraph {
1293            defs: vec![def("a.Foo", SchemaType::s32())],
1294            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1295        };
1296        let g2 = SchemaGraph {
1297            defs: vec![def("a.Foo", SchemaType::s32())],
1298            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1299        };
1300        let merged = merge_agent_graphs([g1, g2]).unwrap();
1301        assert_eq!(merged.defs.len(), 1);
1302        assert_eq!(merged.defs[0].id, TypeId::new("a.Foo"));
1303    }
1304
1305    #[test]
1306    fn defs_with_same_id_but_divergent_bodies_are_rejected() {
1307        let g1 = SchemaGraph {
1308            defs: vec![def("a.Foo", SchemaType::s32())],
1309            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1310        };
1311        let g2 = SchemaGraph {
1312            defs: vec![def("a.Foo", SchemaType::string())],
1313            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1314        };
1315        let err = merge_agent_graphs([g1, g2]).unwrap_err();
1316        assert!(matches!(
1317            err,
1318            MergeError::ConflictingDefinitions { ref id, .. } if id == &TypeId::new("a.Foo")
1319        ));
1320    }
1321
1322    #[test]
1323    fn first_seen_insertion_order_is_preserved() {
1324        let g1 = SchemaGraph {
1325            defs: vec![
1326                def("a.Alpha", SchemaType::bool()),
1327                def("a.Beta", SchemaType::s64()),
1328            ],
1329            root: SchemaType::ref_to(TypeId::new("a.Alpha")),
1330        };
1331        let g2 = SchemaGraph {
1332            defs: vec![
1333                def("a.Gamma", SchemaType::u32()),
1334                def("a.Alpha", SchemaType::bool()), // dup, identical
1335            ],
1336            root: SchemaType::ref_to(TypeId::new("a.Gamma")),
1337        };
1338        let merged = merge_agent_graphs([g1, g2]).unwrap();
1339        let ids: Vec<_> = merged
1340            .defs
1341            .iter()
1342            .map(|d| d.id.as_str().to_string())
1343            .collect();
1344        assert_eq!(ids, vec!["a.Alpha", "a.Beta", "a.Gamma"]);
1345    }
1346
1347    #[test]
1348    fn duplicate_id_with_same_body_keeps_first_seen_name() {
1349        // `SchemaTypeDef::name` is display-only and does not affect
1350        // dedup. When the same id appears twice with the same body but
1351        // different display names, the first-seen name is retained.
1352        let g1 = SchemaGraph {
1353            defs: vec![SchemaTypeDef {
1354                id: TypeId::new("a.Foo"),
1355                name: Some("FooFirst".into()),
1356                body: SchemaType::s32(),
1357            }],
1358            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1359        };
1360        let g2 = SchemaGraph {
1361            defs: vec![SchemaTypeDef {
1362                id: TypeId::new("a.Foo"),
1363                name: Some("FooSecond".into()),
1364                body: SchemaType::s32(),
1365            }],
1366            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1367        };
1368        let merged = merge_agent_graphs([g1, g2]).unwrap();
1369        assert_eq!(merged.defs.len(), 1);
1370        assert_eq!(merged.defs[0].name.as_deref(), Some("FooFirst"));
1371    }
1372
1373    #[test]
1374    fn merged_graph_uses_sentinel_root() {
1375        // Even when input graphs have non-trivial roots (which would
1376        // happen in practice — each one is a per-type try_into_schema_graph
1377        // result), the merged carrier's root is always the sentinel,
1378        // because the agent layer is a multi-root carrier.
1379        let g = SchemaGraph::anonymous(SchemaType::s32());
1380        let merged = merge_agent_graphs([g]).unwrap();
1381        assert_eq!(merged.root, SchemaGraph::empty().root);
1382    }
1383}