Skip to main content

typesayer_types/
field.rs

1// Copyright 2026 Thomas Santerre and Moderately AI Inc.
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Field types, definitions, and values for structured LLM prediction.
6
7use std::{collections::BTreeMap, fmt};
8
9use enumset::EnumSet;
10use modelplease::{MediaKind, MediaSource, SourceKind};
11use serde::{Deserialize, Serialize};
12
13/// The type of a field in a signature.
14///
15/// Exhaustive by design — adding a new variant causes compiler errors at all
16/// unhandled match arms. There is no `Any` or `Custom` escape hatch.
17///
18/// Sum-type variants ([`OneOf`](Self::OneOf), [`AnyOf`](Self::AnyOf)) model
19/// JSON Schema's `oneOf` / `anyOf` composition primitives. JSON Schema's other
20/// composition keywords (`allOf`, `const`, `$ref`, `dependentSchemas`,
21/// `if`/`then`/`else`) are folded or transformed at schema-conversion time into
22/// the existing variants — they have no runtime representation here.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24#[serde(tag = "kind", content = "inner", rename_all = "snake_case")]
25pub enum FieldType {
26    /// A UTF-8 string.
27    String,
28    /// A 64-bit signed integer.
29    Int,
30    /// A 64-bit floating-point number.
31    Float,
32    /// A boolean value.
33    Bool,
34    /// A homogeneous list of values.
35    List(Box<Self>),
36    /// A structured object with named fields.
37    Object(Vec<ObjectField>),
38    /// A string-keyed map with homogeneous values.
39    Map(Box<Self>),
40    /// One of a fixed set of string variants.
41    Enum(Vec<std::string::String>),
42    /// A nullable wrapper around another type.
43    Nullable(Box<Self>),
44    /// Exactly one arm matches. Models JSON Schema's `oneOf`.
45    ///
46    /// When `discriminator` is `Some`, parsing is deterministic via tag
47    /// dispatch — the inbound JSON's `property` field selects the arm by its
48    /// `tags[i]` value. When `discriminator` is `None`, parsing tries every
49    /// arm and demands exactly one succeed; zero matches and multiple matches
50    /// both surface specific errors.
51    OneOf {
52        /// Alternatives, in declaration order.
53        arms: Vec<VariantArm>,
54        /// Discriminator hint for deterministic dispatch when the arms share
55        /// a `const`-valued property. Inferred at schema-conversion time.
56        discriminator: Option<OneOfDiscriminator>,
57    },
58    /// At least one arm matches; first match wins. Models JSON Schema's
59    /// `anyOf`. Parsing tries arms in declared order; the first arm whose
60    /// deserializer succeeds wins, and the rest are not attempted. Suitable
61    /// when arms may legitimately overlap and the consumer is content with
62    /// declared-order priority.
63    AnyOf {
64        /// Alternatives, in declared order.
65        arms: Vec<VariantArm>,
66    },
67    /// A media reference (image / document / audio / video).
68    ///
69    /// `accepted_sources` constrains which [`SourceKind`]s a caller may
70    /// pass at value-construction time — the application preflight uses
71    /// this to reject application configurations whose declared sources
72    /// the model doesn't support.
73    Media {
74        /// Which modality this field carries (`Image`, `Document`, ...).
75        kind: MediaKind,
76        /// Source kinds the slot accepts. Default is "all" when the
77        /// schema doesn't specify; preflight narrows it against the
78        /// model's capability table.
79        accepted_sources: EnumSet<SourceKind>,
80    },
81}
82
83/// One arm of a [`OneOf`](FieldType::OneOf) or [`AnyOf`](FieldType::AnyOf).
84///
85/// Carries both the structural type and a human-readable description so the
86/// chat adapter can render per-arm guidance in the prompt without losing the
87/// `description` keyword from the source JSON Schema.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct VariantArm {
90    /// Description of the arm — typically the `description` keyword from the
91    /// source JSON Schema arm, falling back to the discriminator tag or an
92    /// auto-generated name when absent.
93    pub description: std::string::String,
94    /// The arm's structural type.
95    pub field_type: FieldType,
96}
97
98/// Discriminator hint for a tagged [`OneOf`](FieldType::OneOf).
99///
100/// Present when every arm is an Object that names a single property whose
101/// value is a unique `const` string. The schema converter infers this from
102/// the source `oneOf` structure and optionally cross-checks against an
103/// explicit OpenAPI `discriminator: {propertyName: P}` hint.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct OneOfDiscriminator {
106    /// The property name whose value selects the arm (e.g. `"toolName"`).
107    pub property: std::string::String,
108    /// `tags[i]` is the const value of `property` in `arms[i]`. Parallel to
109    /// the parent's `arms` vector; all values are unique strings.
110    pub tags: Vec<std::string::String>,
111}
112
113impl FieldType {
114    /// Returns a human-readable type label for use in system prompts.
115    #[must_use]
116    pub fn type_label(&self) -> std::string::String {
117        match self {
118            Self::String => "str".to_owned(),
119            Self::Int => "int".to_owned(),
120            Self::Float => "float".to_owned(),
121            Self::Bool => "bool".to_owned(),
122            Self::List(inner) => format!("list[{}]", inner.type_label()),
123            Self::Object(fields) => {
124                let parts: Vec<_> = fields
125                    .iter()
126                    .map(|f| format!("{}: {}", f.name, f.field_type.type_label()))
127                    .collect();
128                format!("{{{}}}", parts.join(", "))
129            }
130            Self::Map(value_type) => format!("map[str, {}]", value_type.type_label()),
131            Self::Enum(variants) => {
132                format!("enum[{}]", variants.join(", "))
133            }
134            Self::Nullable(inner) => format!("optional[{}]", inner.type_label()),
135            Self::OneOf {
136                arms,
137                discriminator,
138            } => discriminator.as_ref().map_or_else(
139                || {
140                    let parts: Vec<_> = arms.iter().map(|a| a.field_type.type_label()).collect();
141                    format!("oneof[{}]", parts.join(" | "))
142                },
143                |disc| format!("oneof[{}: {}]", disc.property, disc.tags.join(" | ")),
144            ),
145            Self::AnyOf { arms } => {
146                let parts: Vec<_> = arms.iter().map(|a| a.field_type.type_label()).collect();
147                format!("anyof[{}]", parts.join(" | "))
148            }
149            Self::Media { kind, .. } => format!("media[{}]", kind.label()),
150        }
151    }
152
153    /// Concrete serialization guidance for an *output* field of this type,
154    /// for the system prompt and the live output-format reminder. `None`
155    /// means no note is needed — a bare string, or a media slot that's
156    /// emitted as an out-of-band content part rather than text.
157    ///
158    /// The type label alone (`list[str]`) doesn't tell a model the wire
159    /// format, so well-behaved prompts still emit code fences, single-key
160    /// wrappers, or markdown lists. Stating the exact shape makes formatting
161    /// the adapter's responsibility, not the caller's. Mirrors DSPy's
162    /// `translate_field_type`.
163    #[must_use]
164    pub fn output_format_hint(&self) -> Option<std::string::String> {
165        match self {
166            Self::String | Self::Media { .. } => None,
167            Self::Int => Some("a single integer".to_owned()),
168            Self::Float => Some("a single number".to_owned()),
169            Self::Bool => Some("`true` or `false`".to_owned()),
170            Self::Enum(variants) => Some(format!("exactly one of: {}", variants.join(", "))),
171            Self::List(inner) => Some(format!(
172                "a JSON array of {}, e.g. [\"...\", \"...\"] — not an object, not a code fence",
173                inner.type_label()
174            )),
175            Self::Object(fields) => {
176                let example = object_payload_example(fields);
177                Some(format!(
178                    "a JSON object matching {} — e.g. {example} — not a code fence",
179                    self.type_label()
180                ))
181            }
182            Self::Map(value_type) => Some(format!(
183                "a JSON object with string keys and {} values — \
184                 e.g. {{\"key1\": ..., \"key2\": ...}} — not a code fence",
185                value_type.type_label()
186            )),
187            Self::Nullable(inner) => {
188                // Nullable's hint says both halves: what a present value
189                // looks like AND when null is the correct emission. The
190                // "when not applicable" framing converts the user's
191                // most-common LLM-emits-null failure mode (model wants
192                // to express "doesn't apply" but the schema author
193                // didn't tell it to drop the marker) into the right
194                // mental model — drop the marker for not-applicable,
195                // emit null only when the value is genuinely null-valued.
196                Some(inner.output_format_hint().map_or_else(
197                    || "a value, or null when the value is not applicable".to_owned(),
198                    |hint| format!("{hint}, or null when the value is not applicable"),
199                ))
200            }
201            Self::OneOf { discriminator, .. } => Some(discriminator.as_ref().map_or_else(
202                || {
203                    "a JSON value matching exactly one of the shapes listed under \"Variant \
204                     shapes\" above"
205                        .to_owned()
206                },
207                |d| {
208                    // Parenthesise the "(see ...)" cross-reference so a wrapping
209                    // `Nullable` can append its ", or null" clause without
210                    // creating "see X above, or null" ambiguity. Including a
211                    // representative tag value as part of the example makes
212                    // the shape concrete without forcing the model to scroll
213                    // back to the variant-shapes block to pick one.
214                    let example_tag = d.tags.first().map_or("...", String::as_str);
215                    format!(
216                        "a JSON object whose `{property}` field selects the variant — \
217                         e.g. {{\"{property}\": \"{example_tag}\", ...}} \
218                         (see \"Variant shapes\" above for each arm's fields)",
219                        property = d.property,
220                    )
221                },
222            )),
223            Self::AnyOf { .. } => Some(
224                "a JSON value matching any of the shapes listed under \"Variant shapes\" \
225                 above (first match wins)"
226                    .to_owned(),
227            ),
228        }
229    }
230}
231
232impl fmt::Display for FieldType {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.write_str(&self.type_label())
235    }
236}
237
238/// Build a tiny example payload for an [`Object`](FieldType::Object) field
239/// to embed in the output-format hint. The type label alone says *what*;
240/// the example says *how* — both pieces together collapse a class of
241/// "model emitted a code-fenced thing in the wrong shape" errors.
242///
243/// Caps at the first three fields and uses a per-type placeholder so the
244/// example stays one line regardless of how wide the declared object is.
245fn object_payload_example(fields: &[ObjectField]) -> String {
246    let parts: Vec<String> = fields
247        .iter()
248        .take(3)
249        .map(|f| format!("\"{}\": {}", f.name, type_placeholder(&f.field_type)))
250        .collect();
251    let suffix = if fields.len() > 3 { ", ..." } else { "" };
252    format!("{{{}{suffix}}}", parts.join(", "))
253}
254
255/// A one-token JSON placeholder for the given type, used in synthetic
256/// example payloads. Concrete enough that the model can pattern-match
257/// (`123` for int, `"..."` for string) without claiming a specific
258/// value the schema does not commit to.
259const fn type_placeholder(field_type: &FieldType) -> &'static str {
260    match field_type {
261        FieldType::String | FieldType::Enum(_) | FieldType::Media { .. } => "\"...\"",
262        FieldType::Int => "123",
263        FieldType::Float => "1.5",
264        FieldType::Bool => "true",
265        FieldType::List(_) => "[...]",
266        FieldType::Object(_) | FieldType::Map(_) => "{...}",
267        FieldType::Nullable(_) => "null",
268        FieldType::OneOf { .. } | FieldType::AnyOf { .. } => "...",
269    }
270}
271
272/// A field within an [`Object`](FieldType::Object) type.
273///
274/// Unlike [`FieldDef`], this has no input/output discriminant — nested object
275/// fields are always part of their parent's structure.
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277pub struct ObjectField {
278    /// The field name.
279    pub name: std::string::String,
280    /// A human-readable description of the field.
281    pub description: std::string::String,
282    /// The type of this field.
283    pub field_type: FieldType,
284}
285
286/// Whether a field is an input or output of a signature.
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
288#[serde(rename_all = "lowercase")]
289pub enum FieldKind {
290    /// An input field provided by the caller.
291    Input,
292    /// An output field produced by the language model.
293    Output,
294}
295
296/// A top-level field definition within a [`Signature`](crate::Signature).
297#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298pub struct FieldDef {
299    /// The field name.
300    pub name: std::string::String,
301    /// A human-readable description of the field.
302    pub description: std::string::String,
303    /// The type of this field.
304    pub field_type: FieldType,
305    /// Whether this is an input or output field.
306    pub kind: FieldKind,
307    /// Whether this input field's value is stable across calls within a
308    /// conversation and therefore eligible to sit inside the cacheable
309    /// prefix of the user message. Default `false` (backwards-compatible):
310    /// only fields a application explicitly declares stable are cached. The
311    /// `ChatAdapter` places a cache breakpoint after
312    /// the last cacheable input field; meaningless on output fields.
313    #[serde(default)]
314    pub cacheable: bool,
315    /// Concrete example values for this field, sourced from the
316    /// JSON Schema `examples` keyword on the originating schema. Rendered
317    /// in the system prompt under the field's description so the model
318    /// sees "what good output looks like" without bloating the
319    /// instructions string. Defaults to empty; the schema converter
320    /// caps at the first three examples to bound prompt size.
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub examples: Vec<serde_json::Value>,
323}
324
325impl FieldDef {
326    /// Create an input field definition.
327    ///
328    /// `field_type` sits between the two `String` args by design: it's the
329    /// only non-string positional arg, so callers cannot silently
330    /// transpose `name` and `description` without a compile error from
331    /// the misplaced `FieldType` enum value.
332    pub fn input(
333        name: impl Into<std::string::String>,
334        field_type: FieldType,
335        description: impl Into<std::string::String>,
336    ) -> Self {
337        Self {
338            name: name.into(),
339            description: description.into(),
340            field_type,
341            kind: FieldKind::Input,
342            cacheable: false,
343            examples: Vec::new(),
344        }
345    }
346
347    /// Create an output field definition. See [`Self::input`] for the
348    /// rationale behind the arg order (`field_type` between the two
349    /// `String` args).
350    pub fn output(
351        name: impl Into<std::string::String>,
352        field_type: FieldType,
353        description: impl Into<std::string::String>,
354    ) -> Self {
355        Self {
356            name: name.into(),
357            description: description.into(),
358            field_type,
359            kind: FieldKind::Output,
360            cacheable: false,
361            examples: Vec::new(),
362        }
363    }
364
365    /// Attach concrete example values to this field. Builder-style for
366    /// test ergonomics — production paths populate [`Self::examples`]
367    /// directly via the JSON Schema converter in `schema.rs`.
368    #[must_use]
369    pub fn with_examples(mut self, examples: Vec<serde_json::Value>) -> Self {
370        self.examples = examples;
371        self
372    }
373}
374
375/// A typed value extracted from an LM completion or supplied as input.
376///
377/// Mirrors [`FieldType`] but holds actual data. Produced by adapter parsing.
378///
379/// # Serde behavior
380///
381/// Uses `#[serde(untagged)]` — serde tries variants in declaration order during
382/// deserialization. This means JSON `42` deserializes as `Int(42)` (not `Float`),
383/// `3.14` as `Float(3.14)`, `true` as `Bool(true)`, etc. This ordering is
384/// intentional and deterministic: whole numbers become `Int`, decimal numbers
385/// become `Float`. Do not reorder variants without considering the serde impact.
386///
387/// The `Media` variant is tagged via its inner [`MediaValue`] struct
388/// (which carries an explicit `kind` discriminator) rather than the
389/// untagged numeric/string variants. Serde tries each untagged variant
390/// in order, and `MediaValue`'s struct shape is unambiguous against
391/// the scalar variants.
392///
393/// The `Variant` arm is placed **before** `Object` so its specific
394/// `{arm_index, value}` shape matches first. The narrow consequence: a real
395/// Object whose declared fields are exactly `{arm_index: <non-negative int>,
396/// value: <any>}` (and nothing else) cannot round-trip through serde-untagged
397/// — it would be parsed as `Variant`. No production schema uses these field
398/// names, so the ambiguity is theoretical, but consumers persisting custom
399/// shapes should avoid this exact field pair.
400#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
401#[serde(untagged)]
402pub enum FieldValue {
403    /// A string value.
404    Str(std::string::String),
405    /// An integer value.
406    Int(i64),
407    /// A floating-point value.
408    Float(f64),
409    /// A boolean value.
410    Bool(bool),
411    /// A list of values.
412    List(Vec<Self>),
413    /// A matched arm of a [`OneOf`](FieldType::OneOf) or [`AnyOf`](FieldType::AnyOf)
414    /// field. `arm_index` is the zero-based position of the matched arm in the
415    /// owning FieldType's `arms` vector; consumers can switch on it without
416    /// re-running arm validation.
417    Variant {
418        /// Zero-based index of the matched arm in the owning FieldType's
419        /// `arms` vector.
420        arm_index: usize,
421        /// The inner value produced by the matched arm's deserializer.
422        value: Box<Self>,
423    },
424    /// A structured object with named fields.
425    Object(BTreeMap<std::string::String, Self>),
426    /// A media reference (image / document / audio / video).
427    Media(MediaValue),
428    /// A null value (from a nullable field).
429    Null,
430}
431
432/// A media field value. Carries the [`MediaSource`] (where the bytes
433/// come from) and a duplicate `kind` discriminator so consumers can
434/// switch on the modality without inspecting the source.
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
436pub struct MediaValue {
437    /// Modality this value represents.
438    pub kind: MediaKind,
439    /// Where the bytes come from.
440    pub source: MediaSource,
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn type_label_primitives() {
449        assert_eq!(FieldType::String.type_label(), "str");
450        assert_eq!(FieldType::Int.type_label(), "int");
451        assert_eq!(FieldType::Float.type_label(), "float");
452        assert_eq!(FieldType::Bool.type_label(), "bool");
453    }
454
455    #[test]
456    fn type_label_nested() {
457        let list_int = FieldType::List(Box::new(FieldType::Int));
458        assert_eq!(list_int.type_label(), "list[int]");
459
460        let nullable_str = FieldType::Nullable(Box::new(FieldType::String));
461        assert_eq!(nullable_str.type_label(), "optional[str]");
462
463        let map_float = FieldType::Map(Box::new(FieldType::Float));
464        assert_eq!(map_float.type_label(), "map[str, float]");
465    }
466
467    #[test]
468    fn type_label_enum() {
469        let e = FieldType::Enum(vec!["a".into(), "b".into(), "c".into()]);
470        assert_eq!(e.type_label(), "enum[a, b, c]");
471    }
472
473    #[test]
474    fn type_label_object() {
475        let obj = FieldType::Object(vec![
476            ObjectField {
477                name: "x".into(),
478                description: "x coord".into(),
479                field_type: FieldType::Int,
480            },
481            ObjectField {
482                name: "y".into(),
483                description: "y coord".into(),
484                field_type: FieldType::Int,
485            },
486        ]);
487        assert_eq!(obj.type_label(), "{x: int, y: int}");
488    }
489
490    #[test]
491    fn field_def_input_constructor() {
492        let f = FieldDef::input("question", FieldType::String, "The user question");
493        assert_eq!(f.name, "question");
494        assert_eq!(f.kind, FieldKind::Input);
495    }
496
497    #[test]
498    fn field_def_output_constructor() {
499        let f = FieldDef::output("answer", FieldType::String, "The answer");
500        assert_eq!(f.name, "answer");
501        assert_eq!(f.kind, FieldKind::Output);
502    }
503
504    #[test]
505    fn field_type_serde_round_trip_primitive() {
506        let ft = FieldType::String;
507        let json = serde_json::to_string(&ft).unwrap();
508        let deserialized: FieldType = serde_json::from_str(&json).unwrap();
509        assert_eq!(ft, deserialized);
510    }
511
512    #[test]
513    fn field_type_serde_round_trip_nested() {
514        let ft = FieldType::List(Box::new(FieldType::Nullable(Box::new(FieldType::Int))));
515        let json = serde_json::to_string(&ft).unwrap();
516        let deserialized: FieldType = serde_json::from_str(&json).unwrap();
517        assert_eq!(ft, deserialized);
518    }
519
520    #[test]
521    fn field_type_serde_round_trip_object() {
522        let ft = FieldType::Object(vec![ObjectField {
523            name: "name".into(),
524            description: "A name".into(),
525            field_type: FieldType::String,
526        }]);
527        let json = serde_json::to_string(&ft).unwrap();
528        let deserialized: FieldType = serde_json::from_str(&json).unwrap();
529        assert_eq!(ft, deserialized);
530    }
531
532    #[test]
533    fn field_value_serde_round_trip() {
534        let val = FieldValue::Object(BTreeMap::from([
535            ("name".into(), FieldValue::Str("Alice".into())),
536            ("age".into(), FieldValue::Int(30)),
537            ("active".into(), FieldValue::Bool(true)),
538        ]));
539        let json = serde_json::to_string(&val).unwrap();
540        let deserialized: FieldValue = serde_json::from_str(&json).unwrap();
541        assert_eq!(val, deserialized);
542    }
543
544    #[test]
545    fn field_def_serde_round_trip() {
546        let fd = FieldDef::input("text", FieldType::String, "Input text");
547        let json = serde_json::to_string(&fd).unwrap();
548        let deserialized: FieldDef = serde_json::from_str(&json).unwrap();
549        assert_eq!(fd, deserialized);
550    }
551
552    #[test]
553    fn output_format_hint_none_for_string_and_media() {
554        assert!(FieldType::String.output_format_hint().is_none());
555        let media = FieldType::Media {
556            kind: MediaKind::Image,
557            accepted_sources: EnumSet::all(),
558        };
559        assert!(media.output_format_hint().is_none());
560    }
561
562    #[test]
563    fn output_format_hint_list_says_json_array() {
564        let hint = FieldType::List(Box::new(FieldType::String))
565            .output_format_hint()
566            .expect("list has a hint");
567        assert!(hint.contains("JSON array"), "got: {hint}");
568        assert!(hint.contains('['), "should show array brackets: {hint}");
569    }
570
571    #[test]
572    fn output_format_hint_bool_says_true_false() {
573        let hint = FieldType::Bool
574            .output_format_hint()
575            .expect("bool has a hint");
576        assert!(
577            hint.contains("true") && hint.contains("false"),
578            "got: {hint}"
579        );
580    }
581
582    #[test]
583    fn output_format_hint_enum_lists_variants() {
584        let hint = FieldType::Enum(vec!["yes".into(), "no".into()])
585            .output_format_hint()
586            .expect("enum has a hint");
587        assert!(hint.contains("yes") && hint.contains("no"), "got: {hint}");
588    }
589
590    #[test]
591    fn output_format_hint_nullable_mentions_null() {
592        let hint = FieldType::Nullable(Box::new(FieldType::List(Box::new(FieldType::String))))
593            .output_format_hint()
594            .expect("nullable has a hint");
595        assert!(hint.contains("null"), "got: {hint}");
596    }
597
598    #[test]
599    fn output_format_hint_object_and_map_say_json_object() {
600        let obj = FieldType::Object(vec![ObjectField {
601            name: "x".into(),
602            description: String::new(),
603            field_type: FieldType::Int,
604        }]);
605        assert!(
606            obj.output_format_hint()
607                .expect("object hint")
608                .contains("JSON object")
609        );
610        let map = FieldType::Map(Box::new(FieldType::Int));
611        assert!(
612            map.output_format_hint()
613                .expect("map hint")
614                .contains("JSON object")
615        );
616    }
617
618    // --- OneOf / AnyOf type-lattice tests ---
619
620    fn variant_arm_obj(name: &str, field_type: FieldType) -> VariantArm {
621        VariantArm {
622            description: format!("arm: {name}"),
623            field_type,
624        }
625    }
626
627    #[test]
628    fn type_label_oneof_tagged_names_discriminator_and_tags() {
629        let ft = FieldType::OneOf {
630            arms: vec![
631                variant_arm_obj("a", FieldType::Object(vec![])),
632                variant_arm_obj("b", FieldType::Object(vec![])),
633            ],
634            discriminator: Some(OneOfDiscriminator {
635                property: "kind".into(),
636                tags: vec!["a".into(), "b".into()],
637            }),
638        };
639        let label = ft.type_label();
640        assert!(label.contains("oneof"), "got: {label}");
641        assert!(label.contains("kind"), "got: {label}");
642        assert!(label.contains('a'), "got: {label}");
643        assert!(label.contains('b'), "got: {label}");
644    }
645
646    #[test]
647    fn type_label_oneof_untagged_lists_arm_labels() {
648        let ft = FieldType::OneOf {
649            arms: vec![
650                variant_arm_obj("int", FieldType::Int),
651                variant_arm_obj("str", FieldType::String),
652            ],
653            discriminator: None,
654        };
655        let label = ft.type_label();
656        assert!(label.starts_with("oneof["), "got: {label}");
657        assert!(label.contains("int"), "got: {label}");
658        assert!(label.contains("str"), "got: {label}");
659    }
660
661    #[test]
662    fn type_label_anyof_lists_arm_labels() {
663        let ft = FieldType::AnyOf {
664            arms: vec![
665                variant_arm_obj("int", FieldType::Int),
666                variant_arm_obj("str", FieldType::String),
667            ],
668        };
669        let label = ft.type_label();
670        assert!(label.starts_with("anyof["), "got: {label}");
671        assert!(label.contains("int"), "got: {label}");
672        assert!(label.contains("str"), "got: {label}");
673    }
674
675    #[test]
676    fn output_format_hint_oneof_tagged_points_to_variant_shapes() {
677        let ft = FieldType::OneOf {
678            arms: vec![variant_arm_obj("a", FieldType::Object(vec![]))],
679            discriminator: Some(OneOfDiscriminator {
680                property: "toolName".into(),
681                tags: vec!["a".into()],
682            }),
683        };
684        let hint = ft.output_format_hint().expect("hint");
685        assert!(hint.contains("toolName"), "got: {hint}");
686        assert!(hint.contains("Variant shapes"), "got: {hint}");
687    }
688
689    #[test]
690    fn output_format_hint_oneof_untagged_points_to_variant_shapes() {
691        let ft = FieldType::OneOf {
692            arms: vec![variant_arm_obj("int", FieldType::Int)],
693            discriminator: None,
694        };
695        let hint = ft.output_format_hint().expect("hint");
696        assert!(hint.contains("exactly one"), "got: {hint}");
697        assert!(hint.contains("Variant shapes"), "got: {hint}");
698    }
699
700    #[test]
701    fn output_format_hint_anyof_mentions_first_match() {
702        let ft = FieldType::AnyOf {
703            arms: vec![variant_arm_obj("int", FieldType::Int)],
704        };
705        let hint = ft.output_format_hint().expect("hint");
706        assert!(hint.contains("first match"), "got: {hint}");
707    }
708
709    #[test]
710    fn type_label_composes_oneof_inside_list() {
711        let ft = FieldType::List(Box::new(FieldType::OneOf {
712            arms: vec![
713                variant_arm_obj("a", FieldType::Object(vec![])),
714                variant_arm_obj("b", FieldType::Object(vec![])),
715            ],
716            discriminator: Some(OneOfDiscriminator {
717                property: "kind".into(),
718                tags: vec!["a".into(), "b".into()],
719            }),
720        }));
721        let label = ft.type_label();
722        assert!(label.starts_with("list[oneof["), "got: {label}");
723        assert!(label.contains("kind"), "got: {label}");
724    }
725
726    #[test]
727    fn field_type_oneof_serde_round_trip() {
728        let ft = FieldType::OneOf {
729            arms: vec![
730                variant_arm_obj("a", FieldType::Object(vec![])),
731                variant_arm_obj("b", FieldType::Object(vec![])),
732            ],
733            discriminator: Some(OneOfDiscriminator {
734                property: "kind".into(),
735                tags: vec!["a".into(), "b".into()],
736            }),
737        };
738        let json = serde_json::to_string(&ft).unwrap();
739        let restored: FieldType = serde_json::from_str(&json).unwrap();
740        assert_eq!(ft, restored);
741    }
742
743    #[test]
744    fn field_type_anyof_serde_round_trip() {
745        let ft = FieldType::AnyOf {
746            arms: vec![
747                variant_arm_obj("int", FieldType::Int),
748                variant_arm_obj("str", FieldType::String),
749            ],
750        };
751        let json = serde_json::to_string(&ft).unwrap();
752        let restored: FieldType = serde_json::from_str(&json).unwrap();
753        assert_eq!(ft, restored);
754    }
755
756    #[test]
757    fn field_value_variant_serde_round_trip() {
758        let value = FieldValue::Variant {
759            arm_index: 1,
760            value: Box::new(FieldValue::Object(BTreeMap::from([(
761                "toolName".into(),
762                FieldValue::Str("ranked_items".into()),
763            )]))),
764        };
765        let json = serde_json::to_string(&value).unwrap();
766        let restored: FieldValue = serde_json::from_str(&json).unwrap();
767        assert_eq!(value, restored);
768    }
769
770    #[test]
771    fn field_value_object_still_round_trips_with_variant_in_lattice() {
772        // Guards the documented serde-untagged-ordering constraint —
773        // a real Object whose declared fields aren't exactly
774        // `{arm_index, value}` must still parse as Object, not Variant.
775        let value = FieldValue::Object(BTreeMap::from([
776            ("name".into(), FieldValue::Str("Alice".into())),
777            ("age".into(), FieldValue::Int(30)),
778            ("active".into(), FieldValue::Bool(true)),
779        ]));
780        let json = serde_json::to_string(&value).unwrap();
781        let restored: FieldValue = serde_json::from_str(&json).unwrap();
782        assert_eq!(value, restored);
783    }
784}