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<T: IntoSchema> IntoSchema for HashSet<T> {
752    fn type_id() -> TypeId {
753        type_id_with_args("std.collections.HashSet", &[T::type_id()])
754    }
755    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
756        SchemaType::list(T::register_in(b))
757    }
758    fn to_value(&self) -> SchemaValue {
759        SchemaValue::List {
760            elements: self.iter().map(|x| x.to_value()).collect(),
761        }
762    }
763}
764
765impl<T: FromSchema + Eq + std::hash::Hash> FromSchema for HashSet<T> {
766    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
767        match v {
768            SchemaValue::List { elements } => elements.iter().map(T::from_value).collect(),
769            other => Err(FromSchemaError::shape_mismatch(
770                "list",
771                value_kind(other),
772                "HashSet",
773            )),
774        }
775    }
776}
777
778impl<K: IntoSchema, V: IntoSchema> IntoSchema for HashMap<K, V> {
779    fn type_id() -> TypeId {
780        type_id_with_args("std.collections.HashMap", &[K::type_id(), V::type_id()])
781    }
782    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
783        SchemaType::map(K::register_in(b), V::register_in(b))
784    }
785    fn to_value(&self) -> SchemaValue {
786        SchemaValue::Map {
787            entries: self
788                .iter()
789                .map(|(k, v)| (k.to_value(), v.to_value()))
790                .collect(),
791        }
792    }
793}
794
795impl<K, V> FromSchema for HashMap<K, V>
796where
797    K: FromSchema + std::hash::Hash + Eq,
798    V: FromSchema,
799{
800    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
801        match v {
802            SchemaValue::Map { entries } => entries
803                .iter()
804                .map(|(k, v)| Ok((K::from_value(k)?, V::from_value(v)?)))
805                .collect(),
806            other => Err(FromSchemaError::shape_mismatch(
807                "map",
808                value_kind(other),
809                "HashMap",
810            )),
811        }
812    }
813}
814
815impl<K: IntoSchema, V: IntoSchema> IntoSchema for BTreeMap<K, V> {
816    fn type_id() -> TypeId {
817        type_id_with_args("alloc.collections.BTreeMap", &[K::type_id(), V::type_id()])
818    }
819    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
820        SchemaType::map(K::register_in(b), V::register_in(b))
821    }
822    fn to_value(&self) -> SchemaValue {
823        SchemaValue::Map {
824            entries: self
825                .iter()
826                .map(|(k, v)| (k.to_value(), v.to_value()))
827                .collect(),
828        }
829    }
830}
831
832impl<K, V> FromSchema for BTreeMap<K, V>
833where
834    K: FromSchema + Ord,
835    V: FromSchema,
836{
837    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
838        match v {
839            SchemaValue::Map { entries } => entries
840                .iter()
841                .map(|(k, v)| Ok((K::from_value(k)?, V::from_value(v)?)))
842                .collect(),
843            other => Err(FromSchemaError::shape_mismatch(
844                "map",
845                value_kind(other),
846                "BTreeMap",
847            )),
848        }
849    }
850}
851
852// =====================================================================
853// Tuples (1..12)
854// =====================================================================
855
856macro_rules! impl_tuple {
857    ( $name:literal => $( $idx:tt : $t:ident ),+ ) => {
858        impl< $( $t : IntoSchema ),+ > IntoSchema for ( $( $t, )+ ) {
859            fn type_id() -> TypeId {
860                type_id_with_args($name, &[ $( <$t as IntoSchema>::type_id() ),+ ])
861            }
862            fn register_in(b: &mut SchemaBuilder) -> SchemaType {
863                SchemaType::tuple(vec![ $( <$t as IntoSchema>::register_in(b) ),+ ])
864            }
865            fn to_value(&self) -> SchemaValue {
866                SchemaValue::Tuple {
867                    elements: vec![ $( IntoSchema::to_value(&self.$idx) ),+ ],
868                }
869            }
870        }
871        impl< $( $t : FromSchema ),+ > FromSchema for ( $( $t, )+ ) {
872            fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
873                match v {
874                    SchemaValue::Tuple { elements } => {
875                        let expected = [ $( stringify!($t) ),+ ].len();
876                        if elements.len() != expected {
877                            return Err(FromSchemaError::custom(format!(
878                                "tuple length mismatch: expected {expected}, got {}",
879                                elements.len()
880                            )));
881                        }
882                        Ok(( $( <$t as FromSchema>::from_value(&elements[$idx])? , )+ ))
883                    }
884                    other => Err(FromSchemaError::shape_mismatch(
885                        "tuple",
886                        value_kind(other),
887                        "tuple",
888                    )),
889                }
890            }
891        }
892    };
893}
894
895impl_tuple!("core.tuple.Tuple1" => 0: T0);
896impl_tuple!("core.tuple.Tuple2" => 0: T0, 1: T1);
897impl_tuple!("core.tuple.Tuple3" => 0: T0, 1: T1, 2: T2);
898impl_tuple!("core.tuple.Tuple4" => 0: T0, 1: T1, 2: T2, 3: T3);
899impl_tuple!("core.tuple.Tuple5" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4);
900impl_tuple!("core.tuple.Tuple6" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4, 5: T5);
901impl_tuple!("core.tuple.Tuple7" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4, 5: T5, 6: T6);
902impl_tuple!("core.tuple.Tuple8" => 0: T0, 1: T1, 2: T2, 3: T3, 4: T4, 5: T5, 6: T6, 7: T7);
903
904impl IntoSchema for () {
905    fn type_id() -> TypeId {
906        TypeId::new("core.tuple.Unit")
907    }
908    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
909        SchemaType::tuple(vec![])
910    }
911    fn to_value(&self) -> SchemaValue {
912        SchemaValue::Tuple { elements: vec![] }
913    }
914}
915
916impl FromSchema for () {
917    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
918        match v {
919            SchemaValue::Tuple { elements } if elements.is_empty() => Ok(()),
920            other => Err(FromSchemaError::shape_mismatch(
921                "unit tuple",
922                value_kind(other),
923                "()",
924            )),
925        }
926    }
927}
928
929// =====================================================================
930// Result<T, E>
931// =====================================================================
932
933/// `true` if `T` is the unit type `()`, whose ok/err branch carries no payload
934/// (`result<_, _>`) and is represented by `None` in the schema and value model.
935fn is_unit_type<T: IntoSchema>() -> bool {
936    T::type_id() == <() as IntoSchema>::type_id()
937}
938
939impl<T: IntoSchema, E: IntoSchema> IntoSchema for Result<T, E> {
940    fn type_id() -> TypeId {
941        type_id_with_args("core.result.Result", &[T::type_id(), E::type_id()])
942    }
943    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
944        let ok = if is_unit_type::<T>() {
945            None
946        } else {
947            Some(Box::new(T::register_in(b)))
948        };
949        let err = if is_unit_type::<E>() {
950            None
951        } else {
952            Some(Box::new(E::register_in(b)))
953        };
954        SchemaType::result(crate::schema::schema_type::ResultSpec { ok, err })
955    }
956    fn to_value(&self) -> SchemaValue {
957        use crate::schema::schema_value::ResultValuePayload;
958        match self {
959            Ok(x) => SchemaValue::Result(ResultValuePayload::Ok {
960                value: if is_unit_type::<T>() {
961                    None
962                } else {
963                    Some(Box::new(x.to_value()))
964                },
965            }),
966            Err(e) => SchemaValue::Result(ResultValuePayload::Err {
967                value: if is_unit_type::<E>() {
968                    None
969                } else {
970                    Some(Box::new(e.to_value()))
971                },
972            }),
973        }
974    }
975}
976
977impl<T: FromSchema, E: FromSchema> FromSchema for Result<T, E> {
978    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
979        use crate::schema::schema_value::ResultValuePayload;
980        let unit = SchemaValue::Tuple { elements: vec![] };
981        match v {
982            SchemaValue::Result(ResultValuePayload::Ok { value }) => {
983                let inner = value.as_deref().unwrap_or(&unit);
984                Ok(Ok(T::from_value(inner)?))
985            }
986            SchemaValue::Result(ResultValuePayload::Err { value }) => {
987                let inner = value.as_deref().unwrap_or(&unit);
988                Ok(Err(E::from_value(inner)?))
989            }
990            other => Err(FromSchemaError::shape_mismatch(
991                "result",
992                value_kind(other),
993                "Result",
994            )),
995        }
996    }
997}
998
999// =====================================================================
1000// chrono::DateTime / std::time
1001// =====================================================================
1002
1003impl IntoSchema for ::chrono::DateTime<::chrono::Utc> {
1004    fn type_id() -> TypeId {
1005        TypeId::new("chrono.DateTime<chrono.Utc>")
1006    }
1007    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
1008        SchemaType::datetime()
1009    }
1010    fn to_value(&self) -> SchemaValue {
1011        SchemaValue::Datetime { value: *self }
1012    }
1013}
1014
1015impl FromSchema for ::chrono::DateTime<::chrono::Utc> {
1016    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1017        match v {
1018            SchemaValue::Datetime { value } => Ok(*value),
1019            other => Err(FromSchemaError::shape_mismatch(
1020                "datetime",
1021                value_kind(other),
1022                "DateTime<Utc>",
1023            )),
1024        }
1025    }
1026}
1027
1028impl IntoSchema for std::time::Duration {
1029    fn type_id() -> TypeId {
1030        TypeId::new("core.time.Duration")
1031    }
1032    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
1033        SchemaType::duration()
1034    }
1035    fn to_value(&self) -> SchemaValue {
1036        let nanos = self.as_nanos();
1037        let clamped: i64 = if nanos > i64::MAX as u128 {
1038            i64::MAX
1039        } else {
1040            nanos as i64
1041        };
1042        SchemaValue::Duration(DurationValuePayload {
1043            nanoseconds: clamped,
1044        })
1045    }
1046}
1047
1048impl FromSchema for std::time::Duration {
1049    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1050        match v {
1051            SchemaValue::Duration(p) => {
1052                if p.nanoseconds < 0 {
1053                    Err(FromSchemaError::custom(
1054                        "std::time::Duration cannot decode a negative duration",
1055                    ))
1056                } else {
1057                    Ok(std::time::Duration::from_nanos(p.nanoseconds as u64))
1058                }
1059            }
1060            other => Err(FromSchemaError::shape_mismatch(
1061                "duration",
1062                value_kind(other),
1063                "Duration",
1064            )),
1065        }
1066    }
1067}
1068
1069// A `std::path::PathBuf` is modelled as the rich `Path` schema type. Only
1070// UTF-8 paths are supported because `SchemaValue::Path` is string-backed.
1071impl IntoSchema for std::path::PathBuf {
1072    fn type_id() -> TypeId {
1073        TypeId::new("std.path.PathBuf")
1074    }
1075    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
1076        use crate::schema::schema_type::{PathDirection, PathKind, PathSpec};
1077        SchemaType::path(PathSpec {
1078            direction: PathDirection::InOut,
1079            kind: PathKind::Any,
1080            allowed_mime_types: None,
1081            allowed_extensions: None,
1082        })
1083    }
1084    fn to_value(&self) -> SchemaValue {
1085        match self.to_str() {
1086            Some(s) => path_to_value(s.to_string()),
1087            None => path_to_value(self.to_string_lossy().into_owned()),
1088        }
1089    }
1090}
1091
1092impl FromSchema for std::path::PathBuf {
1093    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1094        Ok(std::path::PathBuf::from(path_from_value(v, "PathBuf")?))
1095    }
1096}
1097
1098impl IntoSchema for uuid::Uuid {
1099    fn type_id() -> TypeId {
1100        TypeId::new("uuid.Uuid")
1101    }
1102    fn register_in(_builder: &mut SchemaBuilder) -> SchemaType {
1103        SchemaType::uuid()
1104    }
1105    fn to_value(&self) -> SchemaValue {
1106        SchemaValue::Uuid(*self)
1107    }
1108}
1109
1110impl FromSchema for uuid::Uuid {
1111    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1112        match v {
1113            SchemaValue::Uuid(value) => Ok(*value),
1114            other => Err(FromSchemaError::shape_mismatch(
1115                "uuid",
1116                value_kind(other),
1117                "Uuid",
1118            )),
1119        }
1120    }
1121}
1122
1123// `usize` is modelled as a `u64` (the schema model has no platform-width
1124// integer; oplog payloads never need values above `u64::MAX`).
1125impl IntoSchema for usize {
1126    fn type_id() -> TypeId {
1127        TypeId::new("usize")
1128    }
1129    fn register_in(_b: &mut SchemaBuilder) -> SchemaType {
1130        SchemaType::u64()
1131    }
1132    fn to_value(&self) -> SchemaValue {
1133        SchemaValue::U64(*self as u64)
1134    }
1135}
1136
1137impl FromSchema for usize {
1138    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1139        match v {
1140            SchemaValue::U64(x) => Ok(*x as usize),
1141            other => Err(FromSchemaError::shape_mismatch(
1142                "u64",
1143                value_kind(other),
1144                "usize",
1145            )),
1146        }
1147    }
1148}
1149
1150// `std::ops::Bound<T>` is modelled as a 3-case variant
1151// `included(T) | excluded(T) | unbounded`.
1152impl<T: IntoSchema> IntoSchema for std::ops::Bound<T> {
1153    fn type_id() -> TypeId {
1154        type_id_with_args("core.ops.range.Bound", &[T::type_id()])
1155    }
1156    fn register_in(b: &mut SchemaBuilder) -> SchemaType {
1157        let inner = T::register_in(b);
1158        SchemaType::variant(vec![
1159            VariantCaseType {
1160                name: "included".to_string(),
1161                payload: Some(inner.clone()),
1162                metadata: MetadataEnvelope::default(),
1163            },
1164            VariantCaseType {
1165                name: "excluded".to_string(),
1166                payload: Some(inner),
1167                metadata: MetadataEnvelope::default(),
1168            },
1169            VariantCaseType {
1170                name: "unbounded".to_string(),
1171                payload: None,
1172                metadata: MetadataEnvelope::default(),
1173            },
1174        ])
1175    }
1176    fn to_value(&self) -> SchemaValue {
1177        match self {
1178            std::ops::Bound::Included(t) => SchemaValue::Variant(VariantValuePayload {
1179                case: 0,
1180                payload: Some(Box::new(t.to_value())),
1181            }),
1182            std::ops::Bound::Excluded(t) => SchemaValue::Variant(VariantValuePayload {
1183                case: 1,
1184                payload: Some(Box::new(t.to_value())),
1185            }),
1186            std::ops::Bound::Unbounded => SchemaValue::Variant(VariantValuePayload {
1187                case: 2,
1188                payload: None,
1189            }),
1190        }
1191    }
1192}
1193
1194impl<T: FromSchema> FromSchema for std::ops::Bound<T> {
1195    fn from_value(v: &SchemaValue) -> Result<Self, FromSchemaError> {
1196        match v {
1197            SchemaValue::Variant(VariantValuePayload { case, payload }) => match case {
1198                0 => {
1199                    let p = payload.as_ref().ok_or_else(|| {
1200                        FromSchemaError::custom("Bound::Included requires a payload")
1201                    })?;
1202                    Ok(std::ops::Bound::Included(T::from_value(p)?))
1203                }
1204                1 => {
1205                    let p = payload.as_ref().ok_or_else(|| {
1206                        FromSchemaError::custom("Bound::Excluded requires a payload")
1207                    })?;
1208                    Ok(std::ops::Bound::Excluded(T::from_value(p)?))
1209                }
1210                2 => Ok(std::ops::Bound::Unbounded),
1211                other => Err(FromSchemaError::out_of_range(*other, 3, "Bound")),
1212            },
1213            other => Err(FromSchemaError::shape_mismatch(
1214                "variant",
1215                value_kind(other),
1216                "Bound",
1217            )),
1218        }
1219    }
1220}
1221
1222// =====================================================================
1223// Tests
1224// =====================================================================
1225
1226#[cfg(test)]
1227mod result_unit_tests {
1228    use super::*;
1229    use crate::schema::schema_value::ResultValuePayload;
1230    use test_r::test;
1231
1232    #[test]
1233    fn ok_unit_encodes_as_none() {
1234        let v: Result<(), String> = Ok(());
1235        assert_eq!(
1236            v.to_value(),
1237            SchemaValue::Result(ResultValuePayload::Ok { value: None })
1238        );
1239    }
1240
1241    #[test]
1242    fn err_unit_encodes_as_none() {
1243        let v: Result<u32, ()> = Err(());
1244        assert_eq!(
1245            v.to_value(),
1246            SchemaValue::Result(ResultValuePayload::Err { value: None })
1247        );
1248    }
1249
1250    #[test]
1251    fn non_unit_branches_still_carry_payload() {
1252        let ok: Result<u32, String> = Ok(7);
1253        assert_eq!(
1254            ok.to_value(),
1255            SchemaValue::Result(ResultValuePayload::Ok {
1256                value: Some(Box::new(7u32.to_value()))
1257            })
1258        );
1259        let err: Result<u32, String> = Err("boom".to_string());
1260        assert_eq!(
1261            err.to_value(),
1262            SchemaValue::Result(ResultValuePayload::Err {
1263                value: Some(Box::new("boom".to_string().to_value()))
1264            })
1265        );
1266    }
1267
1268    #[test]
1269    fn unit_branch_schema_is_none() {
1270        let mut b = SchemaBuilder::new();
1271        let ty = <Result<(), String> as IntoSchema>::register_in(&mut b);
1272        match ty {
1273            SchemaType::Result { spec, .. } => {
1274                assert!(spec.ok.is_none(), "ok branch should be None for unit");
1275                assert!(spec.err.is_some(), "err branch should carry string");
1276            }
1277            other => panic!("expected result schema, got {other:?}"),
1278        }
1279    }
1280
1281    #[test]
1282    fn ok_unit_roundtrips() {
1283        let v: Result<(), String> = Ok(());
1284        let decoded = <Result<(), String> as FromSchema>::from_value(&v.to_value()).unwrap();
1285        assert_eq!(decoded, Ok(()));
1286    }
1287
1288    #[test]
1289    fn err_unit_roundtrips() {
1290        let v: Result<u32, ()> = Err(());
1291        let decoded = <Result<u32, ()> as FromSchema>::from_value(&v.to_value()).unwrap();
1292        assert_eq!(decoded, Err(()));
1293    }
1294}
1295
1296#[cfg(test)]
1297mod merge_tests {
1298    use super::*;
1299    use test_r::test;
1300
1301    fn def(id: &str, body: SchemaType) -> SchemaTypeDef {
1302        SchemaTypeDef {
1303            id: TypeId::new(id),
1304            name: None,
1305            body,
1306        }
1307    }
1308
1309    #[test]
1310    fn empty_input_produces_empty_merged_graph() {
1311        let merged = merge_agent_graphs(std::iter::empty()).unwrap();
1312        assert!(merged.defs.is_empty());
1313        // root is the sentinel from SchemaGraph::empty()
1314        assert_eq!(merged.root, SchemaGraph::empty().root);
1315    }
1316
1317    #[test]
1318    fn defs_are_deduplicated_by_type_id_when_bodies_match() {
1319        let g1 = SchemaGraph {
1320            defs: vec![def("a.Foo", SchemaType::s32())],
1321            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1322        };
1323        let g2 = SchemaGraph {
1324            defs: vec![def("a.Foo", SchemaType::s32())],
1325            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1326        };
1327        let merged = merge_agent_graphs([g1, g2]).unwrap();
1328        assert_eq!(merged.defs.len(), 1);
1329        assert_eq!(merged.defs[0].id, TypeId::new("a.Foo"));
1330    }
1331
1332    #[test]
1333    fn defs_with_same_id_but_divergent_bodies_are_rejected() {
1334        let g1 = SchemaGraph {
1335            defs: vec![def("a.Foo", SchemaType::s32())],
1336            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1337        };
1338        let g2 = SchemaGraph {
1339            defs: vec![def("a.Foo", SchemaType::string())],
1340            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1341        };
1342        let err = merge_agent_graphs([g1, g2]).unwrap_err();
1343        assert!(matches!(
1344            err,
1345            MergeError::ConflictingDefinitions { ref id, .. } if id == &TypeId::new("a.Foo")
1346        ));
1347    }
1348
1349    #[test]
1350    fn first_seen_insertion_order_is_preserved() {
1351        let g1 = SchemaGraph {
1352            defs: vec![
1353                def("a.Alpha", SchemaType::bool()),
1354                def("a.Beta", SchemaType::s64()),
1355            ],
1356            root: SchemaType::ref_to(TypeId::new("a.Alpha")),
1357        };
1358        let g2 = SchemaGraph {
1359            defs: vec![
1360                def("a.Gamma", SchemaType::u32()),
1361                def("a.Alpha", SchemaType::bool()), // dup, identical
1362            ],
1363            root: SchemaType::ref_to(TypeId::new("a.Gamma")),
1364        };
1365        let merged = merge_agent_graphs([g1, g2]).unwrap();
1366        let ids: Vec<_> = merged
1367            .defs
1368            .iter()
1369            .map(|d| d.id.as_str().to_string())
1370            .collect();
1371        assert_eq!(ids, vec!["a.Alpha", "a.Beta", "a.Gamma"]);
1372    }
1373
1374    #[test]
1375    fn duplicate_id_with_same_body_keeps_first_seen_name() {
1376        // `SchemaTypeDef::name` is display-only and does not affect
1377        // dedup. When the same id appears twice with the same body but
1378        // different display names, the first-seen name is retained.
1379        let g1 = SchemaGraph {
1380            defs: vec![SchemaTypeDef {
1381                id: TypeId::new("a.Foo"),
1382                name: Some("FooFirst".into()),
1383                body: SchemaType::s32(),
1384            }],
1385            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1386        };
1387        let g2 = SchemaGraph {
1388            defs: vec![SchemaTypeDef {
1389                id: TypeId::new("a.Foo"),
1390                name: Some("FooSecond".into()),
1391                body: SchemaType::s32(),
1392            }],
1393            root: SchemaType::ref_to(TypeId::new("a.Foo")),
1394        };
1395        let merged = merge_agent_graphs([g1, g2]).unwrap();
1396        assert_eq!(merged.defs.len(), 1);
1397        assert_eq!(merged.defs[0].name.as_deref(), Some("FooFirst"));
1398    }
1399
1400    #[test]
1401    fn merged_graph_uses_sentinel_root() {
1402        // Even when input graphs have non-trivial roots (which would
1403        // happen in practice — each one is a per-type try_into_schema_graph
1404        // result), the merged carrier's root is always the sentinel,
1405        // because the agent layer is a multi-root carrier.
1406        let g = SchemaGraph::anonymous(SchemaType::s32());
1407        let merged = merge_agent_graphs([g]).unwrap();
1408        assert_eq!(merged.root, SchemaGraph::empty().root);
1409    }
1410}