Skip to main content

turnframe_provider/
dialect.rs

1//! Rewriting a JSON Schema for a provider's dialect, without weakening it.
2//!
3//! Every provider that enforces a schema enforces a different subset of JSON Schema.
4//! **A rewrite may narrow the accepted set, never widen it.** Narrowing costs a re-roll on a
5//! document that would have been fine; widening would leave the profile declaring
6//! [`NativeJsonSchema`](crate::capabilities::StructuredOutputCapability::NativeJsonSchema)
7//! after the guarantee stopped holding. So a constraint this module cannot express fails,
8//! naming the keyword and the JSON pointer: a `Fallback` the router routes around.
9//!
10//! It does the rewrites the shipped adapters need, proves each with [`prove_disjoint`] and
11//! refuses the rest. Why `oneOf` is not simply renamed to `anyOf` is in
12//! [`docs/provider-adapters.md`](https://github.com/turnframe-rs/turnframe/blob/main/docs/provider-adapters.md).
13
14use serde_json::{Map, Value};
15
16use crate::error::{ErrorCode, ProviderError};
17
18/// Longest JSON pointer a [`DialectError`] repeats, in bytes.
19///
20/// Pointers are application-authored schema paths rather than model output, but
21/// a generated schema can nest deeply enough to make an unbounded pointer a
22/// nuisance in a log line.
23pub const MAX_POINTER_LEN: usize = 128;
24
25/// How many nested reference expansions [`inline_definitions`] allows.
26///
27/// This counts *substitutions*, not JSON levels: a definition that references a
28/// definition that references a third is three. Real schemas nest named types a
29/// handful deep, so the default is generous, and the cycle detector rather than
30/// this limit is what catches a definition naming itself.
31///
32/// Structural depth is a different question and belongs to whoever consumes the
33/// inlined schema — a dialect that refuses schemas Gemini cannot decode is
34/// measuring what its decoder does, not what inlining did.
35pub const DEFAULT_MAX_EXPANSIONS: usize = 32;
36
37/// The JSON nesting depth [`inline_definitions`] walks before giving up.
38///
39/// Not a policy, a stack guard: the walk is recursive, and a schema built in
40/// code rather than parsed can nest arbitrarily. A schema that arrived as JSON
41/// is already bounded well below this by the parser.
42pub const MAX_STRUCTURAL_DEPTH: usize = 256;
43
44/// Keywords that annotate rather than constrain.
45///
46/// Dropping one changes no document's validity, so a rewrite may drop them
47/// freely. Everything not on this list is a constraint until proven otherwise.
48pub const ANNOTATIONS: &[&str] = &[
49    "$comment",
50    "default",
51    "deprecated",
52    "description",
53    "examples",
54    "readOnly",
55    "title",
56    "writeOnly",
57];
58
59/// A schema could not be rewritten without weakening it.
60///
61/// `Display` names a JSON pointer and a keyword, both of them from a schema the
62/// application wrote. Neither model output nor user text can reach these
63/// fields.
64#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
65#[non_exhaustive]
66pub enum DialectError {
67    /// A union's branches could not be proven mutually exclusive, so rewriting
68    /// it as `anyOf` would accept documents the source schema rejects.
69    #[error("{pointer}: the branches of {keyword} were not provably disjoint")]
70    UnprovenUnion {
71        /// JSON pointer to the sub-schema carrying the union.
72        pointer: String,
73        /// `oneOf`, always — named so the message reads the same as the others.
74        keyword: String,
75    },
76    /// A `$ref` carries a sibling that constrains, so neither dropping it nor
77    /// lifting it is safe.
78    #[error("{pointer}: $ref carries the constraining sibling {keyword}")]
79    ConstrainingRefSibling {
80        /// JSON pointer to the sub-schema.
81        pointer: String,
82        /// The sibling keyword.
83        keyword: String,
84    },
85    /// A `$ref` names something this module cannot resolve: a remote document,
86    /// or a pointer into a part of the schema that is not a definition table.
87    #[error("{pointer}: $ref is not a local definition reference")]
88    UnresolvableRef {
89        /// JSON pointer to the sub-schema carrying the reference.
90        pointer: String,
91    },
92    /// A definition refers to itself, directly or through others. Inlining it
93    /// does not terminate.
94    #[error("{pointer}: the definition is recursive and cannot be inlined")]
95    RecursiveRef {
96        /// JSON pointer to the reference that closed the cycle.
97        pointer: String,
98    },
99    /// The schema expands or nests deeper than the rewrite allows. `limit`
100    /// says which bound was reached.
101    #[error("{pointer}: the schema goes deeper than {limit} levels")]
102    TooDeep {
103        /// JSON pointer to the level that overflowed.
104        pointer: String,
105        /// The configured limit.
106        limit: usize,
107    },
108    /// A sub-schema is a boolean rather than an object, in a position where the
109    /// rewrite has nothing to attach to.
110    #[error("{pointer}: expected an object sub-schema")]
111    NotAnObject {
112        /// JSON pointer to the offending position.
113        pointer: String,
114    },
115}
116
117impl DialectError {
118    /// The JSON pointer into the source schema.
119    #[must_use]
120    pub fn pointer(&self) -> &str {
121        match self {
122            Self::UnprovenUnion { pointer, .. }
123            | Self::ConstrainingRefSibling { pointer, .. }
124            | Self::UnresolvableRef { pointer }
125            | Self::RecursiveRef { pointer }
126            | Self::TooDeep { pointer, .. }
127            | Self::NotAnObject { pointer } => pointer,
128        }
129    }
130
131    /// The keyword at fault, or a short label for the structural failures.
132    #[must_use]
133    pub fn keyword(&self) -> &str {
134        match self {
135            Self::UnprovenUnion { keyword, .. } | Self::ConstrainingRefSibling { keyword, .. } => {
136                keyword
137            }
138            Self::UnresolvableRef { .. } | Self::RecursiveRef { .. } => "$ref",
139            Self::TooDeep { .. } => "depth",
140            Self::NotAnObject { .. } => "schema",
141        }
142    }
143
144    /// Stable snake-case label of the failure family, for metrics and codes.
145    #[must_use]
146    pub const fn as_str(&self) -> &'static str {
147        match self {
148            Self::UnprovenUnion { .. } => "unproven_union",
149            Self::ConstrainingRefSibling { .. } => "constraining_ref_sibling",
150            Self::UnresolvableRef { .. } => "unresolvable_ref",
151            Self::RecursiveRef { .. } => "recursive_ref",
152            Self::TooDeep { .. } => "too_deep",
153            Self::NotAnObject { .. } => "not_an_object",
154        }
155    }
156
157    /// The error code an adapter attaches when it turns this into a
158    /// [`ProviderError`].
159    #[must_use]
160    pub fn code(&self) -> ErrorCode {
161        ErrorCode::new(self.as_str())
162    }
163}
164
165impl From<DialectError> for ProviderError {
166    /// A schema the dialect cannot carry is an
167    /// [`Unsupported`](crate::error::ProviderErrorKind::Unsupported) feature,
168    /// whose retry class is `Fallback`.
169    ///
170    /// The capability declaration stays true: this provider does enforce
171    /// schemas, it just cannot enforce *this* one. So the router may offer the
172    /// turn to another profile, and must not re-roll against the same one.
173    fn from(value: DialectError) -> Self {
174        Self::unsupported("schema_dialect").with_code(value.as_str())
175    }
176}
177
178/// Appends `segment` to `pointer` as a JSON pointer reference token.
179///
180/// RFC 6901 escapes `~` as `~0` and `/` as `~1`, in that order. A property
181/// named `a/b` would otherwise produce a pointer that reads as two levels and
182/// sends whoever is fixing the schema to the wrong place.
183fn child(pointer: &str, segment: &str) -> String {
184    let escaped = segment.replace('~', "~0").replace('/', "~1");
185    format!("{pointer}/{escaped}")
186}
187
188/// Truncates a pointer for an error message, keeping the leading path.
189fn short(pointer: &str) -> String {
190    if pointer.is_empty() {
191        return String::from("/");
192    }
193    if pointer.len() <= MAX_POINTER_LEN {
194        return pointer.to_owned();
195    }
196    let mut end = MAX_POINTER_LEN;
197    while end > 0 && !pointer.is_char_boundary(end) {
198        end -= 1;
199    }
200    format!("{}…", &pointer[..end])
201}
202
203/// Why a union's branches cannot overlap.
204///
205/// Returned by [`prove_disjoint`] so a caller can record *which* argument
206/// justified the rewrite rather than only that one did.
207#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub enum Disjointness {
210    /// Every branch pins the whole document to literals, and no literal is
211    /// shared.
212    ByConstant,
213    /// No two branches admit a common JSON type.
214    ByType,
215    /// Every branch requires a shared property and pins it to literals no other
216    /// branch accepts.
217    ByDiscriminant {
218        /// The property that separates the branches.
219        property: String,
220    },
221    /// No single property separates every branch, but every *pair* of branches
222    /// disagrees on some pinned property.
223    ///
224    /// This is the shape a union takes once one of its variants is specialized:
225    /// several branches share a tag and are told apart by a second field. It is
226    /// as sound as the single-property case and for the same reason — a pair
227    /// that disagrees on any pinned property has no document in common — and it
228    /// takes a pass over the pairs rather than over the properties.
229    ByDiscriminantPairs,
230}
231
232/// The JSON types a document may have.
233///
234/// A branch that says nothing about type admits all six, which is why a
235/// type-based proof needs every branch to narrow.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237struct TypeSet(u8);
238
239impl TypeSet {
240    const NULL: u8 = 1;
241    const BOOLEAN: u8 = 1 << 1;
242    const INTEGER: u8 = 1 << 2;
243    const NUMBER: u8 = 1 << 3;
244    const STRING: u8 = 1 << 4;
245    const ARRAY: u8 = 1 << 5;
246    const OBJECT: u8 = 1 << 6;
247    const ALL: u8 = 0b0111_1111;
248
249    const fn all() -> Self {
250        Self(Self::ALL)
251    }
252
253    const fn empty() -> Self {
254        Self(0)
255    }
256
257    const fn is_empty(self) -> bool {
258        self.0 == 0
259    }
260
261    fn named(name: &str) -> Self {
262        Self(match name {
263            "null" => Self::NULL,
264            "boolean" => Self::BOOLEAN,
265            // An integer is a number, so a branch typed `number` and one typed
266            // `integer` overlap. Modelling integer as its own bit *plus* the
267            // number bit on the `number` side keeps the intersection honest.
268            "integer" => Self::INTEGER,
269            "number" => Self::NUMBER | Self::INTEGER,
270            "string" => Self::STRING,
271            "array" => Self::ARRAY,
272            "object" => Self::OBJECT,
273            _ => 0,
274        })
275    }
276
277    fn of_value(value: &Value) -> Self {
278        Self(match value {
279            Value::Null => Self::NULL,
280            Value::Bool(_) => Self::BOOLEAN,
281            Value::Number(number) if number.is_f64() => Self::NUMBER,
282            Value::Number(_) => Self::INTEGER,
283            Value::String(_) => Self::STRING,
284            Value::Array(_) => Self::ARRAY,
285            Value::Object(_) => Self::OBJECT,
286        })
287    }
288
289    const fn union(self, other: Self) -> Self {
290        Self(self.0 | other.0)
291    }
292
293    const fn intersects(self, other: Self) -> bool {
294        self.0 & other.0 != 0
295    }
296}
297
298/// The set of JSON types a sub-schema admits, over-approximated.
299///
300/// Over-approximation is the safe direction: a set that is too *large* can only
301/// make a disjointness proof fail, never make a false one succeed.
302fn type_set(schema: &Value) -> TypeSet {
303    let Some(object) = schema.as_object() else {
304        return TypeSet::all();
305    };
306    if let Some(constant) = object.get("const") {
307        return TypeSet::of_value(constant);
308    }
309    if let Some(Value::Array(values)) = object.get("enum") {
310        return values.iter().fold(TypeSet::empty(), |acc, value| {
311            acc.union(TypeSet::of_value(value))
312        });
313    }
314    match object.get("type") {
315        Some(Value::String(name)) => TypeSet::named(name),
316        Some(Value::Array(names)) => names.iter().fold(TypeSet::empty(), |acc, name| {
317            name.as_str()
318                .map_or(TypeSet::all(), |name| acc.union(TypeSet::named(name)))
319        }),
320        _ => TypeSet::all(),
321    }
322}
323
324/// The finite set of literals a sub-schema pins its instance to, if it pins one.
325///
326/// `None` means the sub-schema admits infinitely many documents, or admits a
327/// finite set this function is not clever enough to see. Either way a proof
328/// that depends on it does not go through.
329fn literals(schema: &Value) -> Option<Vec<&Value>> {
330    let object = schema.as_object()?;
331    if let Some(constant) = object.get("const") {
332        return Some(vec![constant]);
333    }
334    match object.get("enum") {
335        Some(Value::Array(values)) if !values.is_empty() => Some(values.iter().collect()),
336        _ => None,
337    }
338}
339
340/// True when two literal sets share no member.
341fn literals_disjoint(left: &[&Value], right: &[&Value]) -> bool {
342    !left.iter().any(|one| right.contains(one))
343}
344
345/// The properties a branch requires, as a set of names.
346fn required_names(schema: &Value) -> Vec<&str> {
347    schema
348        .get("required")
349        .and_then(Value::as_array)
350        .map(|names| names.iter().filter_map(Value::as_str).collect())
351        .unwrap_or_default()
352}
353
354/// Proves that no document can satisfy two of `branches` at once.
355///
356/// Returns the argument that succeeded, or `None` when none of them does.
357/// `None` is not a claim that the branches overlap — only that this function
358/// could not show they do not, which is the direction that keeps a rewrite
359/// sound.
360///
361/// A union of fewer than two branches is trivially disjoint.
362///
363/// ```
364/// use serde_json::json;
365/// use turnframe_provider::dialect::{Disjointness, prove_disjoint};
366///
367/// // A tagged enum, the shape `#[serde(tag = "kind")]` produces.
368/// let tagged = [
369///     json!({"type": "object", "required": ["kind"],
370///            "properties": {"kind": {"const": "a"}}}),
371///     json!({"type": "object", "required": ["kind"],
372///            "properties": {"kind": {"const": "b"}}}),
373/// ];
374/// assert_eq!(
375///     prove_disjoint(&tagged),
376///     Some(Disjointness::ByDiscriminant { property: "kind".to_owned() })
377/// );
378///
379/// // Two open object branches could both match, and are not rewritten.
380/// let open = [json!({"type": "object"}), json!({"type": "object"})];
381/// assert_eq!(prove_disjoint(&open), None);
382/// ```
383#[must_use]
384pub fn prove_disjoint(branches: &[Value]) -> Option<Disjointness> {
385    if branches.len() < 2 {
386        return Some(Disjointness::ByConstant);
387    }
388
389    // By constant: every branch is a finite literal set, pairwise disjoint.
390    let pinned: Option<Vec<Vec<&Value>>> = branches.iter().map(literals).collect();
391    if let Some(sets) = pinned
392        && pairwise(&sets, |left, right| literals_disjoint(left, right))
393    {
394        return Some(Disjointness::ByConstant);
395    }
396
397    // By type: no two branches admit a common JSON type.
398    let types: Vec<TypeSet> = branches.iter().map(type_set).collect();
399    if !types.iter().any(|set| set.is_empty())
400        && pairwise(&types, |left, right| !left.intersects(*right))
401    {
402        return Some(Disjointness::ByType);
403    }
404
405    // By discriminant: a shared required property pinned to disjoint literals.
406    // Candidates come from the first branch, since the property must be
407    // required by every one of them.
408    for candidate in required_names(&branches[0]) {
409        let pinned: Option<Vec<Vec<&Value>>> = branches
410            .iter()
411            .map(|branch| pinned_property(branch, candidate))
412            .collect();
413        if let Some(sets) = pinned
414            && pairwise(&sets, |left, right| literals_disjoint(left, right))
415        {
416            return Some(Disjointness::ByDiscriminant {
417                property: candidate.to_owned(),
418            });
419        }
420    }
421
422    // No single property separates all of them. It is enough that every *pair*
423    // disagrees somewhere: three branches sharing a tag and differing on a
424    // second field are still pairwise exclusive, which is what a union looks
425    // like once one of its variants has been specialized.
426    let candidates: Vec<&str> = required_names(&branches[0]);
427    if !candidates.is_empty()
428        && pairwise(branches, |left, right| {
429            candidates.iter().any(|candidate| {
430                match (
431                    pinned_property(left, candidate),
432                    pinned_property(right, candidate),
433                ) {
434                    (Some(one), Some(other)) => literals_disjoint(&one, &other),
435                    _ => false,
436                }
437            })
438        })
439    {
440        return Some(Disjointness::ByDiscriminantPairs);
441    }
442
443    None
444}
445
446/// The literals a branch pins `property` to, when it requires it and pins it.
447fn pinned_property<'a>(branch: &'a Value, property: &str) -> Option<Vec<&'a Value>> {
448    if !required_names(branch).contains(&property) {
449        return None;
450    }
451    branch.get("properties")?.get(property).and_then(literals)
452}
453
454/// True when `holds` is true for every unordered pair of distinct items.
455fn pairwise<T>(items: &[T], holds: impl Fn(&T, &T) -> bool) -> bool {
456    items
457        .iter()
458        .enumerate()
459        .all(|(index, left)| items[index + 1..].iter().all(|right| holds(left, right)))
460}
461
462/// Rewrites every provably disjoint `oneOf` as `anyOf`, refusing the rest.
463///
464/// The rewrite is exact where it applies: when no document can match two
465/// branches, *exactly one* and *at least one* accept the same set. Where it
466/// does not apply the schema is refused, because renaming the keyword there
467/// would accept documents the source rejects.
468///
469/// # Errors
470///
471/// Returns [`DialectError::UnprovenUnion`] naming the pointer of the first
472/// union it could not prove.
473///
474/// ```
475/// use serde_json::json;
476/// use turnframe_provider::dialect::narrow_unions;
477///
478/// let rewritten = narrow_unions(&json!({
479///     "oneOf": [{"const": "a"}, {"const": "b"}]
480/// }))?;
481/// assert_eq!(rewritten["anyOf"], json!([{"const": "a"}, {"const": "b"}]));
482/// assert!(rewritten.get("oneOf").is_none());
483/// # Ok::<(), turnframe_provider::dialect::DialectError>(())
484/// ```
485pub fn narrow_unions(schema: &Value) -> Result<Value, DialectError> {
486    fn walk(node: &Value, pointer: &str) -> Result<Value, DialectError> {
487        match node {
488            Value::Object(map) => {
489                let mut out = Map::with_capacity(map.len());
490                for (key, value) in map {
491                    let child = child(pointer, key);
492                    if key == "oneOf" {
493                        let branches =
494                            value.as_array().ok_or_else(|| DialectError::NotAnObject {
495                                pointer: short(&child),
496                            })?;
497                        if prove_disjoint(branches).is_none() {
498                            return Err(DialectError::UnprovenUnion {
499                                pointer: short(pointer),
500                                keyword: String::from("oneOf"),
501                            });
502                        }
503                        out.insert(String::from("anyOf"), walk(value, &child)?);
504                    } else {
505                        out.insert(key.clone(), walk(value, &child)?);
506                    }
507                }
508                Ok(Value::Object(out))
509            }
510            Value::Array(items) => items
511                .iter()
512                .enumerate()
513                .map(|(index, item)| walk(item, &child(pointer, &index.to_string())))
514                .collect::<Result<Vec<_>, _>>()
515                .map(Value::Array),
516            other => Ok(other.clone()),
517        }
518    }
519    walk(schema, "")
520}
521
522/// Moves keywords that sit beside a `$ref` onto a single-branch `anyOf`.
523///
524/// JSON Schema 2020-12 lets a `$ref` carry siblings; OpenAI's strict mode does
525/// not. `schemars` emits the shape constantly, because a field's doc comment
526/// becomes a `description` next to the reference to the field's type.
527///
528/// Wrapping the reference in `{"anyOf": [{"$ref": …}], "description": …}` is
529/// exact — a union of one branch accepts precisely that branch — and it keeps
530/// the description, which is the sentence telling the model what the field
531/// means. Dropping it would be sound and would make the model worse.
532///
533/// Only [`ANNOTATIONS`] may be lifted. A sibling that constrains is refused,
534/// because in 2020-12 it applies *alongside* the reference and a single-branch
535/// `anyOf` would drop that conjunction.
536///
537/// # Errors
538///
539/// Returns [`DialectError::ConstrainingRefSibling`] naming the keyword.
540pub fn lift_ref_siblings(schema: &Value) -> Result<Value, DialectError> {
541    fn walk(node: &Value, pointer: &str) -> Result<Value, DialectError> {
542        match node {
543            Value::Object(map) if map.contains_key("$ref") && map.len() > 1 => {
544                let mut out = Map::with_capacity(map.len());
545                for key in map.keys().filter(|key| key.as_str() != "$ref") {
546                    if !ANNOTATIONS.contains(&key.as_str()) {
547                        return Err(DialectError::ConstrainingRefSibling {
548                            pointer: short(pointer),
549                            keyword: key.clone(),
550                        });
551                    }
552                }
553                for (key, value) in map {
554                    if key == "$ref" {
555                        continue;
556                    }
557                    out.insert(key.clone(), value.clone());
558                }
559                out.insert(
560                    String::from("anyOf"),
561                    Value::Array(vec![serde_json::json!({"$ref": map["$ref"].clone()})]),
562                );
563                Ok(Value::Object(out))
564            }
565            Value::Object(map) => map
566                .iter()
567                .map(|(key, value)| {
568                    walk(value, &child(pointer, key)).map(|value| (key.clone(), value))
569                })
570                .collect::<Result<Map<_, _>, _>>()
571                .map(Value::Object),
572            Value::Array(items) => items
573                .iter()
574                .enumerate()
575                .map(|(index, item)| walk(item, &child(pointer, &index.to_string())))
576                .collect::<Result<Vec<_>, _>>()
577                .map(Value::Array),
578            other => Ok(other.clone()),
579        }
580    }
581    walk(schema, "")
582}
583
584/// What [`close_objects`] had to force, so a caller can say so rather than
585/// discover it.
586#[derive(Debug, Clone, PartialEq, Eq, Default)]
587pub struct Closure {
588    /// The rewritten schema.
589    pub schema: Value,
590    /// JSON pointers to properties that were optional in the source and are
591    /// required in the result, most useful when a model starts emitting a
592    /// field it used to omit.
593    pub forced_required: Vec<String>,
594}
595
596/// Closes every object and requires every property it declares.
597///
598/// OpenAI's strict mode demands both. Both are **narrowings**: a document that
599/// satisfies the result satisfies the source, since the result forbids extra
600/// properties the source allowed and forbids omitting properties the source let
601/// you omit.
602///
603/// The narrowing has a consequence worth stating, because it is behavioural
604/// rather than formal. A property that was optional must now be present, so the
605/// model has to emit *something* for it. When the source schema admits `null`
606/// for that property — which is what `Option<T>` produces — the model emits
607/// `null` and nothing is lost. When it does not, the model must invent a value
608/// of the declared type. Mark genuinely optional fields nullable and the
609/// question does not arise; [`Closure::forced_required`] lists the ones this
610/// call had to force, so a schema that gets the treatment wrong is visible
611/// rather than mysterious.
612///
613/// ```
614/// use serde_json::json;
615/// use turnframe_provider::dialect::close_objects;
616///
617/// let closed = close_objects(&json!({
618///     "type": "object",
619///     "properties": {"a": {"type": "string"}, "b": {"type": ["string", "null"]}},
620///     "required": ["a"]
621/// }));
622/// assert_eq!(closed.schema["required"], json!(["a", "b"]));
623/// assert_eq!(closed.schema["additionalProperties"], json!(false));
624/// assert_eq!(closed.forced_required, vec!["/properties/b".to_owned()]);
625/// ```
626#[must_use]
627pub fn close_objects(schema: &Value) -> Closure {
628    fn walk(node: &Value, pointer: &str, forced: &mut Vec<String>) -> Value {
629        match node {
630            Value::Object(map) => {
631                let mut out = Map::with_capacity(map.len() + 2);
632                for (key, value) in map {
633                    out.insert(key.clone(), walk(value, &child(pointer, key), forced));
634                }
635                if let Some(Value::Object(properties)) = map.get("properties") {
636                    let already: Vec<&str> = required_names(node);
637                    for name in properties.keys() {
638                        if !already.contains(&name.as_str()) {
639                            forced.push(short(&child(&child(pointer, "properties"), name)));
640                        }
641                    }
642                    out.insert(
643                        String::from("required"),
644                        Value::Array(
645                            properties
646                                .keys()
647                                .map(|name| Value::String(name.clone()))
648                                .collect(),
649                        ),
650                    );
651                    out.insert(String::from("additionalProperties"), Value::Bool(false));
652                }
653                Value::Object(out)
654            }
655            Value::Array(items) => Value::Array(
656                items
657                    .iter()
658                    .enumerate()
659                    .map(|(index, item)| walk(item, &child(pointer, &index.to_string()), forced))
660                    .collect(),
661            ),
662            other => other.clone(),
663        }
664    }
665    let mut forced_required = Vec::new();
666    let schema = walk(schema, "", &mut forced_required);
667    // The walk finishes a child before its parent, so the raw order is
668    // inside-out. Sorting makes the list read like the schema and makes two
669    // runs over the same schema report the same thing.
670    forced_required.sort();
671    Closure {
672        schema,
673        forced_required,
674    }
675}
676
677/// Replaces every local `$ref` with the definition it names.
678///
679/// For a dialect with no `$ref` — Gemini's is one — this is the only way to
680/// keep a referenced constraint at all. The alternative that suggests itself,
681/// deleting `$defs` and the references into it, is the failure this whole
682/// module exists to prevent: a typed enum behind a reference becomes an
683/// unconstrained value, the schema still looks like a schema, and the model
684/// starts inventing members.
685///
686/// Inlining is exact for an acyclic schema. Where the shape does not permit it,
687/// this refuses:
688///
689/// * a reference to anything but a local definition, since there is nothing to
690///   substitute;
691/// * a definition that reaches itself, since substitution does not terminate;
692/// * a schema that expands past `max_depth`.
693///
694/// Annotation siblings on the reference **win** over the definition's own, which
695/// is what makes the result readable: `schemars` puts the field's documentation
696/// beside the reference and the type's documentation inside it, and the field's
697/// is the more specific of the two. A constraining sibling is refused, for the
698/// reason [`lift_ref_siblings`] gives.
699///
700/// # Errors
701///
702/// Returns the [`DialectError`] naming the pointer that could not be inlined.
703pub fn inline_definitions(schema: &Value, max_expansions: usize) -> Result<Value, DialectError> {
704    /// Where `schemars` and its predecessors put definitions.
705    const TABLES: [&str; 2] = ["$defs", "definitions"];
706
707    struct Inliner<'a> {
708        tables: Vec<&'a Map<String, Value>>,
709        max_expansions: usize,
710    }
711
712    impl Inliner<'_> {
713        /// Resolves `#/$defs/Name`, returning the definition body.
714        fn resolve(&self, reference: &str) -> Option<&Value> {
715            let rest = reference.strip_prefix("#/")?;
716            let (table, name) = rest.split_once('/')?;
717            if !TABLES.contains(&table) || name.contains('/') {
718                return None;
719            }
720            self.tables.iter().find_map(|found| found.get(name))
721        }
722
723        fn walk(
724            &self,
725            node: &Value,
726            pointer: &str,
727            depth: usize,
728            open: &mut Vec<String>,
729        ) -> Result<Value, DialectError> {
730            // Two separate bounds, because they answer two questions. `open`
731            // counts reference substitutions, which is what "how far did
732            // inlining expand" means and what the caller configures. `depth`
733            // counts JSON levels and exists only so the recursion cannot run
734            // off the stack.
735            if open.len() > self.max_expansions {
736                return Err(DialectError::TooDeep {
737                    pointer: short(pointer),
738                    limit: self.max_expansions,
739                });
740            }
741            if depth > MAX_STRUCTURAL_DEPTH {
742                return Err(DialectError::TooDeep {
743                    pointer: short(pointer),
744                    limit: MAX_STRUCTURAL_DEPTH,
745                });
746            }
747            match node {
748                Value::Object(map) if map.contains_key("$ref") => {
749                    let reference =
750                        map["$ref"]
751                            .as_str()
752                            .ok_or_else(|| DialectError::UnresolvableRef {
753                                pointer: short(pointer),
754                            })?;
755                    if open.iter().any(|seen| seen == reference) {
756                        return Err(DialectError::RecursiveRef {
757                            pointer: short(pointer),
758                        });
759                    }
760                    let target =
761                        self.resolve(reference)
762                            .ok_or_else(|| DialectError::UnresolvableRef {
763                                pointer: short(pointer),
764                            })?;
765                    for key in map.keys() {
766                        if key == "$ref" || TABLES.contains(&key.as_str()) {
767                            // The reference itself, and the definition tables it
768                            // resolves against, which a root schema carries
769                            // beside its own `$ref`.
770                            continue;
771                        }
772                        if !ANNOTATIONS.contains(&key.as_str()) {
773                            return Err(DialectError::ConstrainingRefSibling {
774                                pointer: short(pointer),
775                                keyword: key.clone(),
776                            });
777                        }
778                    }
779                    open.push(reference.to_owned());
780                    let expanded = self.walk(target, pointer, depth + 1, open);
781                    open.pop();
782                    let mut expanded = match expanded? {
783                        Value::Object(body) => body,
784                        other => return Ok(other),
785                    };
786                    // The reference's own annotations describe this use of the
787                    // type; the definition's describe the type. Prefer the use.
788                    for (key, value) in map {
789                        if key != "$ref" && !TABLES.contains(&key.as_str()) {
790                            expanded.insert(key.clone(), value.clone());
791                        }
792                    }
793                    Ok(Value::Object(expanded))
794                }
795                Value::Object(map) => map
796                    .iter()
797                    .filter(|(key, _)| !TABLES.contains(&key.as_str()))
798                    .map(|(key, value)| {
799                        self.walk(value, &child(pointer, key), depth + 1, open)
800                            .map(|value| (key.clone(), value))
801                    })
802                    .collect::<Result<Map<_, _>, _>>()
803                    .map(Value::Object),
804                Value::Array(items) => items
805                    .iter()
806                    .enumerate()
807                    .map(|(index, item)| {
808                        self.walk(item, &child(pointer, &index.to_string()), depth + 1, open)
809                    })
810                    .collect::<Result<Vec<_>, _>>()
811                    .map(Value::Array),
812                other => Ok(other.clone()),
813            }
814        }
815    }
816
817    let tables = TABLES
818        .iter()
819        .filter_map(|name| schema.get(*name).and_then(Value::as_object))
820        .collect();
821    let inliner = Inliner {
822        tables,
823        max_expansions,
824    };
825    inliner.walk(schema, "", 0, &mut Vec::new())
826}
827
828/// A union of literal branches, collapsed into one `enum` with its
829/// documentation kept.
830///
831/// A `oneOf` whose every branch pins a single string is *exactly* an `enum` of
832/// those strings, so a dialect with no union keyword can still carry the
833/// constraint. What it cannot carry is the per-branch `description`, which for
834/// a Rust enum is the doc comment on each variant and is often the only place
835/// the model is told what a variant means. Folding those lines into the parent
836/// description keeps them in front of the model.
837///
838/// Returns `None` when the branches are not all literal-pinned, leaving the
839/// caller to translate or refuse the union some other way.
840#[must_use]
841pub fn collapse_literal_union(branches: &[Value]) -> Option<(Vec<Value>, String)> {
842    let mut values = Vec::with_capacity(branches.len());
843    let mut lines = Vec::new();
844    for branch in branches {
845        let pinned = literals(branch)?;
846        if pinned.len() != 1 {
847            return None;
848        }
849        let value = pinned[0];
850        if let (Some(name), Some(doc)) = (
851            value.as_str(),
852            branch.get("description").and_then(Value::as_str),
853        ) {
854            lines.push(format!("«{name}»: {doc}"));
855        }
856        values.push(value.clone());
857    }
858    Some((values, lines.join("\n")))
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use serde_json::json;
865
866    #[test]
867    fn a_tagged_enum_is_disjoint_by_its_tag() {
868        let branches = [
869            json!({"type": "object", "required": ["kind", "value"],
870                   "properties": {"kind": {"const": "token"}, "value": {"type": "string"}}}),
871            json!({"type": "object", "required": ["kind"],
872                   "properties": {"kind": {"const": "new_case"}}}),
873        ];
874        assert_eq!(
875            prove_disjoint(&branches),
876            Some(Disjointness::ByDiscriminant {
877                property: "kind".to_owned()
878            })
879        );
880    }
881
882    /// Three branches sharing a tag, told apart by a second pinned field.
883    ///
884    /// The shape a union takes once one of its variants is specialized: an act
885    /// kind split per operation, so `kind` no longer separates everything and
886    /// `operation` finishes the job. Without this the adapters would refuse a
887    /// schema they can express perfectly well.
888    #[test]
889    fn branches_sharing_a_tag_are_separated_by_a_second_field() {
890        let branches = [
891            json!({"type": "object", "required": ["kind", "operation"],
892                   "properties": {"kind": {"const": "apply"},
893                                  "operation": {"enum": ["a.set", "a.clear"]}}}),
894            json!({"type": "object", "required": ["kind", "operation"],
895                   "properties": {"kind": {"const": "apply"},
896                                  "operation": {"enum": ["a.create"]}}}),
897            json!({"type": "object", "required": ["kind"],
898                   "properties": {"kind": {"const": "start"}}}),
899        ];
900        assert_eq!(
901            prove_disjoint(&branches),
902            Some(Disjointness::ByDiscriminantPairs)
903        );
904    }
905
906    /// And the generalization does not become permissive: two branches sharing
907    /// a tag whose second field also overlaps still cannot be proved.
908    #[test]
909    fn a_shared_tag_with_an_overlapping_second_field_is_not_disjoint() {
910        let branches = [
911            json!({"type": "object", "required": ["kind", "operation"],
912                   "properties": {"kind": {"const": "apply"},
913                                  "operation": {"enum": ["a.set", "a.clear"]}}}),
914            json!({"type": "object", "required": ["kind", "operation"],
915                   "properties": {"kind": {"const": "apply"},
916                                  "operation": {"enum": ["a.clear"]}}}),
917        ];
918        assert_eq!(prove_disjoint(&branches), None);
919    }
920
921    #[test]
922    fn a_repeated_tag_is_not_disjoint() {
923        // The proof must fail: a document with kind "token" matches both.
924        let branches = [
925            json!({"type": "object", "required": ["kind"],
926                   "properties": {"kind": {"const": "token"}}}),
927            json!({"type": "object", "required": ["kind"],
928                   "properties": {"kind": {"const": "token"}}}),
929        ];
930        assert_eq!(prove_disjoint(&branches), None);
931    }
932
933    #[test]
934    fn a_tag_that_one_branch_leaves_optional_is_not_disjoint() {
935        // The second branch accepts a document carrying kind "a", so the two
936        // overlap even though their pinned values differ.
937        let branches = [
938            json!({"type": "object", "required": ["kind"],
939                   "properties": {"kind": {"const": "a"}}}),
940            json!({"type": "object", "properties": {"kind": {"const": "b"}}}),
941        ];
942        assert_eq!(prove_disjoint(&branches), None);
943    }
944
945    #[test]
946    fn distinct_constants_are_disjoint_and_shared_ones_are_not() {
947        assert_eq!(
948            prove_disjoint(&[json!({"const": "a"}), json!({"const": "b"})]),
949            Some(Disjointness::ByConstant)
950        );
951        assert_eq!(
952            prove_disjoint(&[json!({"enum": ["a", "b"]}), json!({"enum": ["b", "c"]})]),
953            None
954        );
955    }
956
957    #[test]
958    fn distinct_types_are_disjoint_but_number_contains_integer() {
959        assert_eq!(
960            prove_disjoint(&[json!({"type": "string"}), json!({"type": "object"})]),
961            Some(Disjointness::ByType)
962        );
963        // 7 satisfies both, so the rewrite must not be allowed.
964        assert_eq!(
965            prove_disjoint(&[json!({"type": "number"}), json!({"type": "integer"})]),
966            None
967        );
968    }
969
970    #[test]
971    fn an_unconstrained_branch_defeats_every_proof() {
972        assert_eq!(
973            prove_disjoint(&[json!({"type": "string"}), json!({})]),
974            None
975        );
976    }
977
978    #[test]
979    fn narrowing_rewrites_what_it_proves_and_refuses_what_it_cannot() {
980        let proven = narrow_unions(&json!({
981            "properties": {"t": {"oneOf": [{"const": "a"}, {"const": "b"}]}}
982        }))
983        .expect("provably disjoint");
984        assert_eq!(
985            proven["properties"]["t"]["anyOf"],
986            json!([{"const": "a"}, {"const": "b"}])
987        );
988
989        let error = narrow_unions(&json!({
990            "properties": {"t": {"oneOf": [{"type": "object"}, {"type": "object"}]}}
991        }))
992        .expect_err("two open objects overlap");
993        assert_eq!(error.keyword(), "oneOf");
994        assert_eq!(error.pointer(), "/properties/t");
995        assert_eq!(error.as_str(), "unproven_union");
996    }
997
998    #[test]
999    fn a_ref_keeps_its_description_and_refuses_a_constraint() {
1000        let lifted = lift_ref_siblings(&json!({
1001            "properties": {"a": {"$ref": "#/$defs/X", "description": "the a"}}
1002        }))
1003        .expect("an annotation lifts");
1004        assert_eq!(
1005            lifted["properties"]["a"],
1006            json!({"description": "the a", "anyOf": [{"$ref": "#/$defs/X"}]})
1007        );
1008
1009        let error = lift_ref_siblings(&json!({
1010            "properties": {"a": {"$ref": "#/$defs/X", "minLength": 3}}
1011        }))
1012        .expect_err("a constraint cannot be lifted");
1013        assert_eq!(error.keyword(), "minLength");
1014        assert_eq!(error.as_str(), "constraining_ref_sibling");
1015    }
1016
1017    #[test]
1018    fn closing_forces_every_property_and_names_what_it_forced() {
1019        let closed = close_objects(&json!({
1020            "type": "object",
1021            "required": ["a"],
1022            "properties": {
1023                "a": {"type": "string"},
1024                "b": {"type": ["string", "null"]},
1025                "c": {"type": "object", "properties": {"d": {"type": "string"}}}
1026            }
1027        }));
1028        assert_eq!(closed.schema["required"], json!(["a", "b", "c"]));
1029        assert_eq!(closed.schema["additionalProperties"], json!(false));
1030        assert_eq!(
1031            closed.schema["properties"]["c"]["additionalProperties"],
1032            json!(false)
1033        );
1034        assert_eq!(
1035            closed.forced_required,
1036            vec![
1037                "/properties/b".to_owned(),
1038                "/properties/c".to_owned(),
1039                "/properties/c/properties/d".to_owned()
1040            ]
1041        );
1042    }
1043
1044    #[test]
1045    fn inlining_substitutes_a_definition_and_prefers_the_field_documentation() {
1046        let inlined = inline_definitions(
1047            &json!({
1048                "type": "object",
1049                "properties": {"a": {"$ref": "#/$defs/Name", "description": "this field"}},
1050                "$defs": {"Name": {"type": "string", "description": "the type", "minLength": 1}}
1051            }),
1052            DEFAULT_MAX_EXPANSIONS,
1053        )
1054        .expect("acyclic");
1055        assert_eq!(
1056            inlined["properties"]["a"],
1057            json!({"type": "string", "description": "this field", "minLength": 1}),
1058            "the constraint survives and the field's own sentence wins"
1059        );
1060        assert!(inlined.get("$defs").is_none(), "the table is consumed");
1061    }
1062
1063    #[test]
1064    fn inlining_refuses_a_recursive_definition_rather_than_looping() {
1065        let error = inline_definitions(
1066            &json!({
1067                "$ref": "#/$defs/Node",
1068                "$defs": {"Node": {"type": "object", "properties": {"next": {"$ref": "#/$defs/Node"}}}}
1069            }),
1070            DEFAULT_MAX_EXPANSIONS,
1071        )
1072        .expect_err("recursion has no finite expansion");
1073        assert_eq!(error.as_str(), "recursive_ref");
1074    }
1075
1076    #[test]
1077    fn inlining_refuses_a_reference_it_cannot_resolve() {
1078        for reference in [
1079            "https://example.test/schema#/$defs/X",
1080            "#/components/schemas/X",
1081            "#/$defs/Missing",
1082        ] {
1083            let error = inline_definitions(&json!({"$ref": reference}), DEFAULT_MAX_EXPANSIONS)
1084                .expect_err("unresolvable");
1085            assert_eq!(error.as_str(), "unresolvable_ref", "{reference}");
1086        }
1087    }
1088
1089    #[test]
1090    fn a_literal_union_collapses_and_keeps_each_variant_sentence() {
1091        let (values, doc) = collapse_literal_union(&[
1092            json!({"const": "plan", "description": "a finished plan"}),
1093            json!({"const": "read_requests", "description": "reads to perform"}),
1094        ])
1095        .expect("all branches pin one literal");
1096        assert_eq!(values, vec![json!("plan"), json!("read_requests")]);
1097        assert_eq!(
1098            doc,
1099            "«plan»: a finished plan\n«read_requests»: reads to perform"
1100        );
1101
1102        assert!(collapse_literal_union(&[json!({"type": "string"})]).is_none());
1103    }
1104
1105    #[test]
1106    fn a_dialect_failure_is_a_fallback_not_a_retry() {
1107        use crate::error::{ProviderErrorKind, RetryClass};
1108        let error = ProviderError::from(DialectError::UnprovenUnion {
1109            pointer: String::from("/x"),
1110            keyword: String::from("oneOf"),
1111        });
1112        assert!(matches!(
1113            error.kind(),
1114            ProviderErrorKind::Unsupported { .. }
1115        ));
1116        assert_eq!(error.retry_class(), RetryClass::Fallback);
1117        assert_eq!(
1118            error.code().map(|code| code.as_str().to_owned()),
1119            Some("unproven_union".to_owned())
1120        );
1121    }
1122
1123    #[test]
1124    fn an_awkward_property_name_is_escaped_into_the_pointer() {
1125        // Unescaped, `a/b` would read as two levels and send whoever is fixing
1126        // the schema to a path that does not exist.
1127        let error = narrow_unions(&json!({
1128            "properties": {"a/b~c": {"oneOf": [{"type": "object"}, {"type": "object"}]}}
1129        }))
1130        .expect_err("an unprovable union under an awkward name");
1131        assert_eq!(error.pointer(), "/properties/a~1b~0c");
1132    }
1133
1134    #[test]
1135    fn the_limit_counts_expansions_rather_than_json_levels() {
1136        // Structural nesting alone is not expansion. A deeply nested schema
1137        // with no references inlines to itself, and refusing it here would
1138        // report a limit that has nothing to do with what inlining did.
1139        let mut deep = json!({"type": "string"});
1140        for _ in 0..40 {
1141            deep = json!({"type": "array", "items": deep});
1142        }
1143        assert!(
1144            inline_definitions(&deep, 2).is_ok(),
1145            "no references to expand"
1146        );
1147
1148        // A chain of three definitions is three expansions, and a limit of two
1149        // stops it.
1150        let chained = json!({
1151            "$ref": "#/$defs/A",
1152            "$defs": {
1153                "A": {"type": "object", "properties": {"b": {"$ref": "#/$defs/B"}}},
1154                "B": {"type": "object", "properties": {"c": {"$ref": "#/$defs/C"}}},
1155                "C": {"type": "string"}
1156            }
1157        });
1158        assert!(inline_definitions(&chained, 8).is_ok());
1159        let error = inline_definitions(&chained, 2).expect_err("three deep");
1160        assert!(
1161            matches!(error, DialectError::TooDeep { limit: 2, .. }),
1162            "{error:?}"
1163        );
1164    }
1165
1166    #[test]
1167    fn a_long_pointer_is_truncated_on_a_character_boundary() {
1168        let deep = format!("/{}", "à".repeat(200));
1169        let error = DialectError::TooDeep {
1170            pointer: short(&deep),
1171            limit: 4,
1172        };
1173        assert!(error.pointer().len() <= MAX_POINTER_LEN + 4);
1174        assert!(error.pointer().ends_with('…'));
1175    }
1176}