Skip to main content

data_source/
compiled_schema.rs

1//! Shared CloudFormation resource-provider schema model and the raw-JSON →
2//! compiled-schema transform.
3//!
4//! This is the single source of truth for turning a raw CloudFormation registry
5//! schema (`$ref`, `type`, `enum`, `/properties/...` paths, `allOf`+`if`, …) into
6//! the compiled representation that the schema validator consumes.
7//!
8//! - At **build time**, `codegen_schema_validator` compiles every bundled schema
9//!   with [`compile_schema`] and serializes the results to `compiled_schemas.json`
10//!   (embedded into the binary). `BTreeMap` fields make that output deterministic.
11//! - At **run time**, the `schema-validator` crate applies additional/overlay
12//!   schemas by calling [`compile_schema`] and converting the result into its own
13//!   runtime schema type. Routing overlays through this exact function guarantees
14//!   the *transform* is the same one bundled schemas go through.
15//!
16//! The transform is shared; the **input** is not. Bundled schemas are compiled
17//! from the build pipeline's patched archive, which adds keywords the raw registry
18//! does not carry (case-insensitive enums, `requiredOr`/`requiredXor`,
19//! `dependentExcluded`, injected conditional `allOf` fragments). A caller-supplied
20//! overlay is compiled straight from the JSON it provides, so anything the
21//! pipeline would have contributed is absent unless the caller states it
22//! explicitly. Callers must not assume an overlay for a bundled type reproduces
23//! that type's enriched schema.
24//!
25//! This module is intentionally dependency-free (only `serde`/`serde_json` +
26//! `std`) and is **not** behind the `maintenance` feature, so the runtime can use the
27//! transform without pulling in the build pipeline's heavy dependencies.
28
29use serde::{Deserialize, Serialize};
30use std::collections::BTreeMap;
31
32/// Named constants for every raw JSON-schema and CloudFormation provider-schema
33/// keyword the compiler reads, grouped by how the compiled model represents
34/// them. This module is the single source of truth for the vocabulary shared
35/// between the build-time compilation pipeline and the runtime overlay
36/// preflight in `schema-validator`.
37pub mod keywords {
38    // ─── Reference prefix ───────────────────────────────────────────────────
39
40    /// The JSON pointer prefix under which definition references live.
41    pub const DEFINITIONS_REF_PREFIX: &str = "#/definitions/";
42
43    /// The JSON pointer prefix that metadata path arrays (`readOnlyProperties`,
44    /// etc.) use to indicate a property path.
45    pub const PROPERTIES_PATH_PREFIX: &str = "/properties/";
46
47    // ─── Reference keyword ──────────────────────────────────────────────────
48
49    /// The `$ref` keyword, pointing at a definition.
50    pub const REF: &str = "$ref";
51
52    // ─── Schema map keywords ────────────────────────────────────────────────
53    //
54    // These name JSON objects whose values are themselves schema objects,
55    // compiled recursively into keyed maps.
56
57    pub const PROPERTIES: &str = "properties";
58    pub const DEFINITIONS: &str = "definitions";
59    pub const PATTERN_PROPERTIES: &str = "patternProperties";
60
61    /// The set of keywords naming schema-level maps compiled into keyed
62    /// `BTreeMap<String, PropSchema>` fields.
63    pub const SCHEMA_MAPS: &[&str] = &[PROPERTIES, DEFINITIONS, PATTERN_PROPERTIES];
64
65    // ─── Composition keywords ───────────────────────────────────────────────
66    //
67    // Arrays of subschemas, each compiled into `Vec<SubSchema>` or split
68    // into conditional entries.
69
70    pub const ALL_OF: &str = "allOf";
71    pub const ANY_OF: &str = "anyOf";
72    pub const ONE_OF: &str = "oneOf";
73
74    /// The full set of composition array keywords the compiler handles.
75    pub const COMPOSITION: &[&str] = &[ALL_OF, ANY_OF, ONE_OF];
76
77    // ─── Conditional keywords ───────────────────────────────────────────────
78    //
79    // The `if`/`then`/`else` conditional structure, only recognized inside
80    // `allOf` entries by the compiler.
81
82    pub const IF: &str = "if";
83    pub const THEN: &str = "then";
84    pub const ELSE: &str = "else";
85
86    /// The set of conditional branch keywords the compiler recognizes inside
87    /// `allOf` entries.
88    pub const CONDITIONALS: &[&str] = &[IF, THEN, ELSE];
89
90    // ─── Metadata pointer arrays ────────────────────────────────────────────
91    //
92    // Top-level arrays whose entries are `/properties/...` JSON pointers
93    // converted to dot-notation property paths.
94
95    pub const READ_ONLY_PROPERTIES: &str = "readOnlyProperties";
96    pub const WRITE_ONLY_PROPERTIES: &str = "writeOnlyProperties";
97    pub const CREATE_ONLY_PROPERTIES: &str = "createOnlyProperties";
98    pub const DEPRECATED_PROPERTIES: &str = "deprecatedProperties";
99    pub const CONDITIONAL_CREATE_ONLY_PROPERTIES: &str = "conditionalCreateOnlyProperties";
100    pub const PRIMARY_IDENTIFIER: &str = "primaryIdentifier";
101
102    /// Metadata pointer arrays that carry `/properties/...` paths.
103    pub const METADATA_POINTER_ARRAYS: &[&str] = &[
104        READ_ONLY_PROPERTIES,
105        WRITE_ONLY_PROPERTIES,
106        CREATE_ONLY_PROPERTIES,
107        DEPRECATED_PROPERTIES,
108        CONDITIONAL_CREATE_ONLY_PROPERTIES,
109        PRIMARY_IDENTIFIER,
110    ];
111
112    // ─── String-array constraint keywords ───────────────────────────────────
113    //
114    // Keywords whose value is an array of property-name strings.
115
116    pub const REQUIRED: &str = "required";
117    pub const REQUIRED_OR: &str = "requiredOr";
118    pub const REQUIRED_XOR: &str = "requiredXor";
119
120    /// Keywords whose value is a JSON array of property-name strings, validated
121    /// as `Vec<String>`.
122    pub const STRING_ARRAY_CONSTRAINTS: &[&str] = &[REQUIRED, REQUIRED_OR, REQUIRED_XOR];
123
124    // ─── Dependency map keywords ────────────────────────────────────────────
125    //
126    // Keywords whose value is `{ "trigger": ["dep1", "dep2"] }`.
127
128    pub const DEPENDENT_REQUIRED: &str = "dependentRequired";
129    pub const DEPENDENT_EXCLUDED: &str = "dependentExcluded";
130
131    /// Keywords compiled into `BTreeMap<String, Vec<String>>` dependency maps.
132    pub const DEPENDENCY_MAPS: &[&str] = &[DEPENDENT_REQUIRED, DEPENDENT_EXCLUDED];
133
134    // ─── Enum keywords ──────────────────────────────────────────────────────
135
136    pub const ENUM: &str = "enum";
137    pub const ENUM_CASE_INSENSITIVE: &str = "enumCaseInsensitive";
138    pub const NOT: &str = "not";
139
140    /// The two enum array keywords (exact and case-insensitive).
141    pub const ENUM_KEYWORDS: &[&str] = &[ENUM, ENUM_CASE_INSENSITIVE];
142
143    // ─── Numeric constraint keywords (f64) ──────────────────────────────────
144
145    pub const MINIMUM: &str = "minimum";
146    pub const MAXIMUM: &str = "maximum";
147    pub const EXCLUSIVE_MINIMUM: &str = "exclusiveMinimum";
148    pub const EXCLUSIVE_MAXIMUM: &str = "exclusiveMaximum";
149
150    /// Keywords compiled into `Option<f64>` numeric bounds.
151    pub const NUMERIC_CONSTRAINTS: &[&str] = &[MINIMUM, MAXIMUM, EXCLUSIVE_MINIMUM, EXCLUSIVE_MAXIMUM];
152
153    // ─── Unsigned integer constraint keywords (u64) ─────────────────────────
154
155    pub const MIN_LENGTH: &str = "minLength";
156    pub const MAX_LENGTH: &str = "maxLength";
157    pub const MIN_ITEMS: &str = "minItems";
158    pub const MAX_ITEMS: &str = "maxItems";
159    pub const MIN_PROPERTIES: &str = "minProperties";
160    pub const MAX_PROPERTIES: &str = "maxProperties";
161
162    /// Keywords compiled into `Option<u64>` length/count bounds.
163    pub const U64_CONSTRAINTS: &[&str] =
164        &[MIN_LENGTH, MAX_LENGTH, MIN_ITEMS, MAX_ITEMS, MIN_PROPERTIES, MAX_PROPERTIES];
165
166    // ─── Boolean constraint keywords ────────────────────────────────────────
167
168    pub const ADDITIONAL_PROPERTIES: &str = "additionalProperties";
169    pub const UNIQUE_ITEMS: &str = "uniqueItems";
170
171    /// Keywords compiled into `Option<bool>` flags.
172    pub const BOOL_CONSTRAINTS: &[&str] = &[ADDITIONAL_PROPERTIES, UNIQUE_ITEMS];
173
174    // ─── String constraint/metadata keywords ────────────────────────────────
175
176    pub const PATTERN: &str = "pattern";
177    pub const FORMAT: &str = "format";
178    pub const DESCRIPTION: &str = "description";
179    pub const REPLACEMENT_STRATEGY: &str = "replacementStrategy";
180    pub const DOCUMENTATION_URL: &str = "documentationUrl";
181    pub const SOURCE_URL: &str = "sourceUrl";
182
183    /// Keywords compiled into `Option<String>` string values.
184    pub const STRING_CONSTRAINTS: &[&str] =
185        &[PATTERN, FORMAT, DESCRIPTION, REPLACEMENT_STRATEGY, DOCUMENTATION_URL, SOURCE_URL];
186
187    // ─── Other compiled keywords ────────────────────────────────────────────
188
189    pub const TYPE: &str = "type";
190    pub const CONST: &str = "const";
191    pub const ITEMS: &str = "items";
192    pub const TYPE_NAME: &str = "typeName";
193
194    // ─── Unrepresented validation keywords ──────────────────────────────────
195    //
196    // JSON Schema validation keywords that exist in the spec but have no field
197    // in the compiled schema model, so nothing enforces them. An overlay
198    // stating any of these would silently weaken the author's intent.
199
200    pub const UNREPRESENTED_VALIDATION_KEYWORDS: &[&str] =
201        &["propertyNames", "contains", "minContains", "maxContains", "additionalItems", "unevaluatedProperties"];
202
203    // ─── multipleOf keyword ─────────────────────────────────────────────────
204
205    pub const MULTIPLE_OF: &str = "multipleOf";
206
207    // ─── dependencies keyword ───────────────────────────────────────────────
208    //
209    // Draft-07 `dependencies` can be array-form (unioned into
210    // `dependentRequired`) or schema-form (not represented).
211
212    pub const DEPENDENCIES: &str = "dependencies";
213
214    // ─── Relationship ref keyword ───────────────────────────────────────────
215
216    pub const RELATIONSHIP_REF: &str = "relationshipRef";
217
218    // ─── Annotation keywords ────────────────────────────────────────────────
219    //
220    // Keywords that carry no validation meaning and may appear beside a `$ref`
221    // without causing an error.
222
223    pub const REF_ANNOTATION_KEYWORDS: &[&str] = &[
224        "description",
225        "markdownDescription",
226        "title",
227        "examples",
228        "default",
229        "$comment",
230        "insertionOrder",
231        RELATIONSHIP_REF,
232    ];
233
234    // ─── compile_sub fields ─────────────────────────────────────────────────
235    //
236    // `compile_sub` now compiles a composition branch into a full `PropSchema`,
237    // so it reads every constraint keyword that `compile_prop` reads. The
238    // overlay's `COMPOSITION_ALLOWED_FIELDS` is validated against what the
239    // runtime actually *evaluates* for composition entries - a separate,
240    // narrower set managed in `schema-validator`.
241
242    /// Fields read by `compile_sub`: since `SubSchema` is now `PropSchema`, this
243    /// is the full set of property-schema keywords.
244    pub const COMPILE_SUB_READS_ALL_PROP_FIELDS: bool = true;
245
246    // ─── compile_condition_schema fields ────────────────────────────────────
247    //
248    // The exact set of keywords `compile_condition_schema` reads from an `if`
249    // schema object.
250
251    /// Fields read by `compile_condition_schema`: what a conditional `if` block
252    /// may contain.
253    pub const COMPILE_CONDITION_SCHEMA_FIELDS: &[&str] = &[PROPERTIES, REQUIRED, TYPE, ANY_OF];
254}
255
256#[derive(Serialize, Deserialize)]
257pub struct CompiledSchema {
258    pub type_name: String,
259    #[serde(default)]
260    pub properties: BTreeMap<String, PropSchema>,
261    #[serde(default)]
262    pub definitions: BTreeMap<String, PropSchema>,
263    #[serde(default)]
264    pub required: Vec<String>,
265    #[serde(default)]
266    pub additional_properties: Option<bool>,
267    #[serde(default)]
268    pub read_only_properties: Vec<String>,
269    #[serde(default, skip_serializing_if = "Vec::is_empty")]
270    pub write_only_properties: Vec<String>,
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub create_only_properties: Vec<String>,
273    #[serde(default, skip_serializing_if = "Vec::is_empty")]
274    pub deprecated_properties: Vec<String>,
275    #[serde(default, skip_serializing_if = "Vec::is_empty")]
276    pub conditional_create_only_properties: Vec<String>,
277    #[serde(default, skip_serializing_if = "Vec::is_empty")]
278    pub primary_identifier: Vec<String>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub replacement_strategy: Option<String>,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub documentation_url: Option<String>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub source_url: Option<String>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub description: Option<String>,
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub all_of: Vec<SubSchema>,
289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
290    pub any_of: Vec<SubSchema>,
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub one_of: Vec<SubSchema>,
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub if_then_else: Vec<IfThenElse>,
295    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
296    pub dependent_required: BTreeMap<String, Vec<String>>,
297    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
298    pub dependent_excluded: BTreeMap<String, Vec<String>>,
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    pub required_or: Vec<String>,
301    #[serde(default, skip_serializing_if = "Vec::is_empty")]
302    pub required_xor: Vec<String>,
303}
304
305#[derive(Serialize, Deserialize, Default)]
306pub struct IfThenElse {
307    pub condition: ConditionSchema,
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub then_schema: Option<SubSchema>,
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub else_schema: Option<SubSchema>,
312}
313
314#[derive(Serialize, Deserialize, Default)]
315pub struct ConditionSchema {
316    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
317    pub properties: BTreeMap<String, PropSchema>,
318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
319    pub required: Vec<String>,
320    /// The instance type the condition requires (`if: {"type": ...}`). A
321    /// condition stating a type only matches an instance of that type; resource
322    /// roots are always objects, so `"object"` is a no-op there while any other
323    /// type makes the condition unsatisfiable at the root.
324    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
325    pub prop_type: Option<PropType>,
326    #[serde(default, skip_serializing_if = "Vec::is_empty")]
327    pub any_of: Vec<ConditionSchema>,
328}
329
330#[derive(Serialize, Deserialize, Default)]
331pub struct PropSchema {
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub ref_name: Option<String>,
334    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
335    pub prop_type: Option<PropType>,
336    #[serde(default, rename = "enum", skip_serializing_if = "Vec::is_empty")]
337    pub enum_values: Vec<serde_json::Value>,
338    #[serde(default, skip_serializing_if = "Vec::is_empty")]
339    pub enum_case_insensitive: Vec<serde_json::Value>,
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub not_enum: Vec<serde_json::Value>,
342    #[serde(default, rename = "const", skip_serializing_if = "Option::is_none")]
343    pub const_value: Option<serde_json::Value>,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub pattern: Option<String>,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub minimum: Option<f64>,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    pub maximum: Option<f64>,
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub exclusive_minimum: Option<f64>,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub exclusive_maximum: Option<f64>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub multiple_of: Option<f64>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub min_length: Option<u64>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub max_length: Option<u64>,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub min_items: Option<u64>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub max_items: Option<u64>,
364    /// `None` when the source schema omits `uniqueItems`, so an overlay that
365    /// explicitly sets it to `false` can relax a bundled `true`. Serialization
366    /// only emits the field when it is `true`, keeping the encoding identical to
367    /// the plain-boolean form this replaced.
368    #[serde(default, skip_serializing_if = "skip_unless_true")]
369    pub unique_items: Option<bool>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub min_properties: Option<u64>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub max_properties: Option<u64>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub format: Option<String>,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub description: Option<String>,
378    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
379    pub properties: BTreeMap<String, PropSchema>,
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub required: Vec<String>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub additional_properties: Option<bool>,
384    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
385    pub pattern_properties: BTreeMap<String, PropSchema>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub items: Option<Box<PropSchema>>,
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub all_of: Vec<SubSchema>,
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub any_of: Vec<SubSchema>,
392    #[serde(default, skip_serializing_if = "Vec::is_empty")]
393    pub one_of: Vec<SubSchema>,
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub if_then_else: Vec<IfThenElse>,
396    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
397    pub dependent_required: BTreeMap<String, Vec<String>>,
398    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
399    pub dependent_excluded: BTreeMap<String, Vec<String>>,
400    #[serde(default, skip_serializing_if = "Vec::is_empty")]
401    pub required_or: Vec<String>,
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub required_xor: Vec<String>,
404}
405fn skip_unless_true(value: &Option<bool>) -> bool {
406    *value != Some(true)
407}
408
409/// A composition branch is now a full property schema - every constraint that
410/// `compile_prop` produces is available in a branch and evaluated by the runtime.
411/// This alias maintains naming clarity at usage sites.
412pub type SubSchema = PropSchema;
413
414#[derive(Serialize, Deserialize)]
415#[serde(untagged)]
416pub enum PropType {
417    Single(String),
418    Multi(Vec<String>),
419}
420
421/// Convert `/properties/X/Y/Z` paths to `X.Y.Z` dot notation.
422fn convert_property_paths(raw: &serde_json::Value) -> Vec<String> {
423    raw.as_array()
424        .map(|arr| {
425            arr.iter()
426                .filter_map(|v| {
427                    v.as_str()
428                        .and_then(|s| s.strip_prefix(keywords::PROPERTIES_PATH_PREFIX))
429                        .map(|s| s.replace('/', "."))
430                })
431                .collect()
432        })
433        .unwrap_or_default()
434}
435
436/// How to treat constraint keywords written beside a `$ref`.
437///
438/// Draft-07 - the dialect provider schemas are written against, and the one the
439/// CloudFormation registry itself validates with - ignores every keyword beside
440/// a `$ref`. The build pipeline compiles bundled schemas with [`Self::Ignore`]
441/// so the engine never enforces more than CloudFormation's own contract. Overlay
442/// schemas are compiled with [`Self::Enforce`]: the author supplied the sibling
443/// constraints deliberately, and they are merged onto the referenced definition
444/// at validation time (`PropSchema::resolve`).
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
446pub enum RefSiblings {
447    /// Compile constraint siblings beside a `$ref` and enforce them.
448    Enforce,
449    /// Drop everything beside a `$ref`, matching draft-07 evaluation.
450    Ignore,
451}
452
453/// Compiles a raw provider schema with [`RefSiblings::Enforce`] - the overlay
454/// path. The build pipeline uses [`compile_schema_with`] and
455/// [`RefSiblings::Ignore`] instead; see [`RefSiblings`].
456pub fn compile_schema(type_name: &str, raw: &serde_json::Value) -> CompiledSchema {
457    compile_schema_with(type_name, raw, RefSiblings::Enforce)
458}
459
460/// Compiles a raw CloudFormation resource provider schema into the compiled
461/// representation.
462///
463/// This transform is richer than the one that produced older committed
464/// `compiled_schemas.json` artifacts (full composition branches, property-level
465/// conditionals, `multipleOf`, draft-07 array-form `dependencies`), so
466/// **regenerating the committed artifact changes what bundled schemas enforce**.
467/// A regeneration must be validated against the full template corpus on both
468/// engines before it is committed.
469pub fn compile_schema_with(type_name: &str, raw: &serde_json::Value, ref_siblings: RefSiblings) -> CompiledSchema {
470    let mut defs = BTreeMap::new();
471    if let Some(d) = raw.get(keywords::DEFINITIONS).and_then(|v| v.as_object()) {
472        for (k, v) in d {
473            defs.insert(k.clone(), compile_prop_with(v, ref_siblings));
474        }
475    }
476    let mut props = BTreeMap::new();
477    if let Some(p) = raw.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
478        for (k, v) in p {
479            props.insert(k.clone(), compile_prop_with(v, ref_siblings));
480        }
481    }
482
483    let mut all_of = Vec::new();
484    let mut if_then_else = Vec::new();
485    if let Some(arr) = raw.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
486        for item in arr {
487            if item.get(keywords::IF).is_some() {
488                if let Some(ite) = compile_if_then_else(item, ref_siblings) {
489                    if_then_else.push(ite);
490                }
491            } else {
492                all_of.push(compile_sub(item, ref_siblings));
493            }
494        }
495    }
496
497    CompiledSchema {
498        type_name: type_name.to_string(),
499        properties: props,
500        definitions: defs,
501        required: str_arr(raw.get(keywords::REQUIRED)),
502        additional_properties: raw.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
503        read_only_properties: convert_property_paths(
504            raw.get(keywords::READ_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
505        ),
506        write_only_properties: convert_property_paths(
507            raw.get(keywords::WRITE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
508        ),
509        create_only_properties: convert_property_paths(
510            raw.get(keywords::CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
511        ),
512        deprecated_properties: convert_property_paths(
513            raw.get(keywords::DEPRECATED_PROPERTIES).unwrap_or(&serde_json::Value::Null),
514        ),
515        conditional_create_only_properties: convert_property_paths(
516            raw.get(keywords::CONDITIONAL_CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
517        ),
518        primary_identifier: convert_property_paths(
519            raw.get(keywords::PRIMARY_IDENTIFIER).unwrap_or(&serde_json::Value::Null),
520        ),
521        replacement_strategy: raw
522            .get(keywords::REPLACEMENT_STRATEGY)
523            .and_then(|v| v.as_str())
524            .filter(|s| !s.is_empty())
525            .map(String::from),
526        documentation_url: raw
527            .get(keywords::DOCUMENTATION_URL)
528            .and_then(|v| v.as_str())
529            .filter(|s| !s.is_empty())
530            .map(String::from),
531        source_url: raw.get(keywords::SOURCE_URL).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
532        description: raw
533            .get(keywords::DESCRIPTION)
534            .and_then(|v| v.as_str())
535            .filter(|s| !s.is_empty())
536            .map(String::from),
537        all_of,
538        any_of: compile_subs(raw.get(keywords::ANY_OF), ref_siblings),
539        one_of: compile_subs(raw.get(keywords::ONE_OF), ref_siblings),
540        if_then_else,
541        dependent_required: compile_dependent_required(raw),
542        dependent_excluded: str_map(raw.get(keywords::DEPENDENT_EXCLUDED)),
543        required_or: str_arr(raw.get(keywords::REQUIRED_OR)),
544        required_xor: str_arr(raw.get(keywords::REQUIRED_XOR)),
545    }
546}
547
548/// Compile `dependentRequired` and draft-07 `dependencies` (array-form) into a
549/// unified dependent_required map.
550fn compile_dependent_required(raw: &serde_json::Value) -> BTreeMap<String, Vec<String>> {
551    let mut dep_req = str_map(raw.get(keywords::DEPENDENT_REQUIRED));
552    if let Some(deps_obj) = raw.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
553        for (trigger, value) in deps_obj {
554            if let Some(arr) = value.as_array() {
555                let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
556                if !names.is_empty() {
557                    dep_req.entry(trigger.clone()).or_default().extend(names);
558                }
559            }
560        }
561    }
562    for deps in dep_req.values_mut() {
563        deps.sort();
564        deps.dedup();
565    }
566    dep_req
567}
568
569fn compile_if_then_else(raw: &serde_json::Value, ref_siblings: RefSiblings) -> Option<IfThenElse> {
570    let if_val = raw.get(keywords::IF)?;
571    let condition = compile_condition_schema(if_val, ref_siblings);
572    let then_schema = raw.get(keywords::THEN).map(|branch| compile_sub(branch, ref_siblings));
573    let else_schema = raw.get(keywords::ELSE).map(|branch| compile_sub(branch, ref_siblings));
574    if then_schema.is_none() && else_schema.is_none() {
575        return None;
576    }
577    Some(IfThenElse { condition, then_schema, else_schema })
578}
579
580fn compile_condition_schema(raw: &serde_json::Value, ref_siblings: RefSiblings) -> ConditionSchema {
581    let obj = match raw.as_object() {
582        Some(o) => o,
583        None => return ConditionSchema::default(),
584    };
585    let mut props = BTreeMap::new();
586    if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
587        for (k, v) in p {
588            props.insert(k.clone(), compile_prop_with(v, ref_siblings));
589        }
590    }
591    let any_of = obj
592        .get(keywords::ANY_OF)
593        .and_then(|v| v.as_array())
594        .map(|arr| arr.iter().map(|entry| compile_condition_schema(entry, ref_siblings)).collect())
595        .unwrap_or_default();
596    let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
597    ConditionSchema { properties: props, required: str_arr(obj.get(keywords::REQUIRED)), prop_type, any_of }
598}
599
600/// Compiles a raw `type` keyword value into the [`PropType`] representation:
601/// a single name, an array of names, or (for a malformed value the preflight
602/// rejects on overlay input) the historical `"string"` fallback.
603fn compile_prop_type(raw: &serde_json::Value) -> PropType {
604    match raw {
605        serde_json::Value::String(s) => PropType::Single(s.clone()),
606        serde_json::Value::Array(a) => PropType::Multi(a.iter().filter_map(|v| v.as_str().map(String::from)).collect()),
607        _ => PropType::Single("string".into()),
608    }
609}
610
611fn compile_prop_with(raw: &serde_json::Value, ref_siblings: RefSiblings) -> PropSchema {
612    let obj = match raw.as_object() {
613        Some(o) => o,
614        None => return PropSchema::default(),
615    };
616    // When a $ref is present, compile it as the ref_name. Under
617    // [`RefSiblings::Enforce`], represented sibling constraints beside the
618    // reference are additionally compiled and merged at validation time via
619    // `PropSchema::resolve`. Under [`RefSiblings::Ignore`] - draft-07
620    // evaluation, used for bundled schemas - everything beside the reference is
621    // dropped. Annotations beside a $ref are ignored either way.
622    let raw_ref = obj.get(keywords::REF).and_then(|v| v.as_str());
623    let ref_name = raw_ref.and_then(|ref_str| ref_str.strip_prefix(keywords::DEFINITIONS_REF_PREFIX).map(String::from));
624    if raw_ref.is_some() && ref_siblings == RefSiblings::Ignore {
625        return PropSchema { ref_name, ..Default::default() };
626    }
627    // If the property is $ref-only (no other constraint keywords), return early.
628    if ref_name.is_some() {
629        let has_constraint_siblings = obj.keys().any(|key| {
630            key != keywords::REF
631                && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())
632                && key != keywords::RELATIONSHIP_REF
633        });
634        if !has_constraint_siblings {
635            return PropSchema { ref_name, ..Default::default() };
636        }
637    }
638
639    let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
640    let mut sub_props = BTreeMap::new();
641    if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
642        for (k, v) in p {
643            sub_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
644        }
645    }
646    let mut pat_props = BTreeMap::new();
647    if let Some(p) = obj.get(keywords::PATTERN_PROPERTIES).and_then(|v| v.as_object()) {
648        for (k, v) in p {
649            pat_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
650        }
651    }
652    let items = obj.get(keywords::ITEMS).map(|v| Box::new(compile_prop_with(v, ref_siblings)));
653
654    // Compile draft-07 `dependencies` array-form into dependent_required.
655    let mut dep_req = str_map(obj.get(keywords::DEPENDENT_REQUIRED).cloned().as_ref());
656    if let Some(deps_obj) = obj.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
657        for (trigger, value) in deps_obj {
658            if let Some(arr) = value.as_array() {
659                let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
660                if !names.is_empty() {
661                    dep_req.entry(trigger.clone()).or_default().extend(names);
662                }
663            }
664            // Schema-form dependencies are not represented; they are handled by
665            // the overlay preflight rejection path.
666        }
667    }
668    // Deduplicate dependency arrays.
669    for deps in dep_req.values_mut() {
670        deps.sort();
671        deps.dedup();
672    }
673
674    // Compile direct if/then/else at property level.
675    let mut if_then_else = Vec::new();
676    if obj.get(keywords::IF).is_some()
677        && let Some(ite) = compile_if_then_else(raw, ref_siblings)
678    {
679        if_then_else.push(ite);
680    }
681
682    // Compile allOf, splitting conditionals.
683    let mut all_of_branches = Vec::new();
684    if let Some(arr) = obj.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
685        for item in arr {
686            if item.get(keywords::IF).is_some() {
687                if let Some(ite) = compile_if_then_else(item, ref_siblings) {
688                    if_then_else.push(ite);
689                }
690            } else {
691                all_of_branches.push(compile_sub(item, ref_siblings));
692            }
693        }
694    }
695
696    PropSchema {
697        ref_name,
698        prop_type,
699        enum_values: obj.get(keywords::ENUM).and_then(|v| v.as_array()).cloned().unwrap_or_default(),
700        enum_case_insensitive: obj
701            .get(keywords::ENUM_CASE_INSENSITIVE)
702            .and_then(|v| v.as_array())
703            .cloned()
704            .unwrap_or_default(),
705        not_enum: obj
706            .get(keywords::NOT)
707            .and_then(|v| v.get(keywords::ENUM))
708            .and_then(|v| v.as_array())
709            .cloned()
710            .unwrap_or_default(),
711        const_value: obj.get(keywords::CONST).cloned(),
712        pattern: obj.get(keywords::PATTERN).and_then(|v| v.as_str()).map(String::from),
713        minimum: obj.get(keywords::MINIMUM).and_then(|v| v.as_f64()),
714        maximum: obj.get(keywords::MAXIMUM).and_then(|v| v.as_f64()),
715        exclusive_minimum: obj.get(keywords::EXCLUSIVE_MINIMUM).and_then(|v| v.as_f64()),
716        exclusive_maximum: obj.get(keywords::EXCLUSIVE_MAXIMUM).and_then(|v| v.as_f64()),
717        multiple_of: obj.get(keywords::MULTIPLE_OF).and_then(|v| v.as_f64()),
718        min_length: obj.get(keywords::MIN_LENGTH).and_then(|v| v.as_u64()),
719        max_length: obj.get(keywords::MAX_LENGTH).and_then(|v| v.as_u64()),
720        min_items: obj.get(keywords::MIN_ITEMS).and_then(|v| v.as_u64()),
721        max_items: obj.get(keywords::MAX_ITEMS).and_then(|v| v.as_u64()),
722        unique_items: obj.get(keywords::UNIQUE_ITEMS).and_then(|v| v.as_bool()),
723        min_properties: obj.get(keywords::MIN_PROPERTIES).and_then(|v| v.as_u64()),
724        max_properties: obj.get(keywords::MAX_PROPERTIES).and_then(|v| v.as_u64()),
725        format: obj.get(keywords::FORMAT).and_then(|v| v.as_str()).map(String::from),
726        description: obj
727            .get(keywords::DESCRIPTION)
728            .and_then(|v| v.as_str())
729            .filter(|s| !s.is_empty())
730            .map(String::from),
731        properties: sub_props,
732        required: str_arr(obj.get(keywords::REQUIRED).cloned().as_ref()),
733        additional_properties: obj.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
734        pattern_properties: pat_props,
735        items,
736        all_of: all_of_branches,
737        any_of: compile_subs(obj.get(keywords::ANY_OF).cloned().as_ref(), ref_siblings),
738        one_of: compile_subs(obj.get(keywords::ONE_OF).cloned().as_ref(), ref_siblings),
739        if_then_else,
740        dependent_required: dep_req,
741        dependent_excluded: str_map(obj.get(keywords::DEPENDENT_EXCLUDED).cloned().as_ref()),
742        required_or: str_arr(obj.get(keywords::REQUIRED_OR).cloned().as_ref()),
743        required_xor: str_arr(obj.get(keywords::REQUIRED_XOR).cloned().as_ref()),
744    }
745}
746
747fn compile_sub(raw: &serde_json::Value, ref_siblings: RefSiblings) -> SubSchema {
748    compile_prop_with(raw, ref_siblings)
749}
750
751fn compile_subs(val: Option<&serde_json::Value>, ref_siblings: RefSiblings) -> Vec<SubSchema> {
752    let Some(arr) = val.and_then(|v| v.as_array()) else {
753        return Vec::new();
754    };
755    arr.iter().map(|entry| compile_sub(entry, ref_siblings)).collect()
756}
757
758fn str_arr(val: Option<&serde_json::Value>) -> Vec<String> {
759    val.and_then(|v| v.as_array())
760        .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
761        .unwrap_or_default()
762}
763
764fn str_map(val: Option<&serde_json::Value>) -> BTreeMap<String, Vec<String>> {
765    val.and_then(|v| v.as_object())
766        .map(|m| m.iter().map(|(k, v)| (k.clone(), str_arr(Some(v)))).collect())
767        .unwrap_or_default()
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use serde_json::json;
774
775    #[test]
776    fn compiles_properties_definitions_and_refs() {
777        let compiled = compile_schema(
778            "AWS::Test::T",
779            &json!({
780                "properties": {
781                    "Name": { "type": "string", "pattern": "^a", "minLength": 1 },
782                    "Cfg": { "$ref": "#/definitions/Config" },
783                    "Kinds": { "type": ["string", "null"] }
784                },
785                "definitions": {
786                    "Config": {
787                        "type": "object",
788                        "required": ["Inner"],
789                        "requiredOr": ["First", "Second"],
790                        "requiredXor": ["Left", "Right"]
791                    }
792                },
793                "required": ["Name"],
794                "additionalProperties": false
795            }),
796        );
797        assert_eq!(compiled.type_name, "AWS::Test::T");
798        assert_eq!(compiled.required, vec!["Name".to_string()]);
799        assert_eq!(compiled.additional_properties, Some(false));
800        assert_eq!(compiled.properties["Cfg"].ref_name.as_deref(), Some("Config"));
801        assert_eq!(compiled.definitions["Config"].required, vec!["Inner".to_string()]);
802        assert_eq!(compiled.definitions["Config"].required_or, vec!["First".to_string(), "Second".to_string()]);
803        assert_eq!(compiled.definitions["Config"].required_xor, vec!["Left".to_string(), "Right".to_string()]);
804        assert_eq!(compiled.properties["Name"].pattern.as_deref(), Some("^a"));
805        assert_eq!(compiled.properties["Name"].min_length, Some(1));
806        match compiled.properties["Kinds"].prop_type.as_ref().expect("a multi type is compiled") {
807            PropType::Multi(names) => assert_eq!(names, &vec!["string".to_string(), "null".to_string()]),
808            PropType::Single(name) => panic!("expected a multi type, got {name}"),
809        }
810    }
811
812    #[test]
813    fn converts_property_pointer_paths_to_dot_notation() {
814        let compiled = compile_schema(
815            "AWS::Test::T",
816            &json!({
817                "readOnlyProperties": ["/properties/Arn", "/properties/Nested/Id"],
818                "writeOnlyProperties": ["/properties/Secret"],
819                "createOnlyProperties": ["/properties/Name"],
820                "deprecatedProperties": ["/properties/Old"],
821                "conditionalCreateOnlyProperties": ["/properties/Maybe"],
822                "primaryIdentifier": ["/properties/Name"]
823            }),
824        );
825        assert_eq!(compiled.read_only_properties, vec!["Arn".to_string(), "Nested.Id".to_string()]);
826        assert_eq!(compiled.write_only_properties, vec!["Secret".to_string()]);
827        assert_eq!(compiled.create_only_properties, vec!["Name".to_string()]);
828        assert_eq!(compiled.deprecated_properties, vec!["Old".to_string()]);
829        assert_eq!(compiled.conditional_create_only_properties, vec!["Maybe".to_string()]);
830        assert_eq!(compiled.primary_identifier, vec!["Name".to_string()]);
831    }
832
833    #[test]
834    fn splits_all_of_into_plain_and_conditional_entries() {
835        let compiled = compile_schema(
836            "AWS::Test::T",
837            &json!({
838                "allOf": [
839                    { "required": ["Plain"] },
840                    { "if": { "properties": { "A": { "enum": ["x"] } } }, "then": { "required": ["B"] } },
841                    { "if": { "properties": { "A": { "enum": ["y"] } } } }
842                ]
843            }),
844        );
845        assert_eq!(compiled.all_of.len(), 1, "plain entries stay in all_of");
846        assert_eq!(compiled.all_of[0].required, vec!["Plain".to_string()]);
847        assert_eq!(compiled.if_then_else.len(), 1, "an if with neither then nor else is dropped");
848        assert_eq!(compiled.if_then_else[0].then_schema.as_ref().expect("then branch").required, vec!["B".to_string()]);
849    }
850
851    #[test]
852    fn preserves_unique_items_presence() {
853        let compiled = compile_schema(
854            "AWS::Test::T",
855            &json!({
856                "properties": {
857                    "Strict": { "type": "array", "uniqueItems": true },
858                    "Relaxed": { "type": "array", "uniqueItems": false },
859                    "Silent": { "type": "array" }
860                }
861            }),
862        );
863        assert_eq!(compiled.properties["Strict"].unique_items, Some(true));
864        assert_eq!(
865            compiled.properties["Relaxed"].unique_items,
866            Some(false),
867            "an explicit false must be distinguishable from an omitted keyword"
868        );
869        assert_eq!(compiled.properties["Silent"].unique_items, None);
870    }
871
872    #[test]
873    fn unique_items_serialization_only_emits_true() {
874        // The committed `compiled_schemas.json` was produced when this field was a
875        // plain bool that was skipped unless true; the encoding must not change.
876        let compiled = compile_schema(
877            "AWS::Test::T",
878            &json!({
879                "properties": {
880                    "Strict": { "uniqueItems": true },
881                    "Relaxed": { "uniqueItems": false },
882                    "Silent": {}
883                }
884            }),
885        );
886        let json = serde_json::to_value(&compiled).expect("a compiled schema serializes");
887        let properties = json.get("properties").and_then(|v| v.as_object()).expect("properties are serialized");
888        assert_eq!(properties["Strict"].get("unique_items"), Some(&json!(true)));
889        assert!(properties["Relaxed"].get("unique_items").is_none(), "explicit false must be omitted");
890        assert!(properties["Silent"].get("unique_items").is_none(), "an absent keyword must be omitted");
891    }
892
893    #[test]
894    fn compiles_both_enum_representations_and_constraint_keywords() {
895        let compiled = compile_schema(
896            "AWS::Test::T",
897            &json!({
898                "properties": {
899                    "Exact": { "enum": ["a", "b"] },
900                    "Insensitive": { "enumCaseInsensitive": ["a", "b"] },
901                    "Excluded": { "not": { "enum": ["bad"] } },
902                    "Fixed": { "const": 7 }
903                },
904                "dependentRequired": { "A": ["B"] },
905                "dependentExcluded": { "C": ["D"] },
906                "requiredOr": ["A", "B"],
907                "requiredXor": ["C", "D"]
908            }),
909        );
910        assert_eq!(compiled.properties["Exact"].enum_values, vec![json!("a"), json!("b")]);
911        assert_eq!(compiled.properties["Insensitive"].enum_case_insensitive, vec![json!("a"), json!("b")]);
912        assert_eq!(compiled.properties["Excluded"].not_enum, vec![json!("bad")]);
913        assert_eq!(compiled.properties["Fixed"].const_value, Some(json!(7)));
914        assert_eq!(compiled.dependent_required.get("A"), Some(&vec!["B".to_string()]));
915        assert_eq!(compiled.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
916        assert_eq!(compiled.required_or, vec!["A".to_string(), "B".to_string()]);
917        assert_eq!(compiled.required_xor, vec!["C".to_string(), "D".to_string()]);
918    }
919
920    #[test]
921    fn compiles_nested_properties_items_and_pattern_properties() {
922        let compiled = compile_schema(
923            "AWS::Test::T",
924            &json!({
925                "properties": {
926                    "Cfg": { "type": "object", "properties": { "Inner": { "type": "string" } } },
927                    "Arr": { "type": "array", "items": { "type": "string", "maxLength": 3 } },
928                    "Map": { "type": "object", "patternProperties": { "^k$": { "type": "integer" } } }
929                }
930            }),
931        );
932        assert!(compiled.properties["Cfg"].properties["Inner"].prop_type.is_some(), "nested type is compiled");
933        assert_eq!(compiled.properties["Arr"].items.as_ref().expect("items").max_length, Some(3));
934        assert!(compiled.properties["Map"].pattern_properties.contains_key("^k$"));
935    }
936
937    // ─── Vocabulary pinning tests ───────────────────────────────────────────
938
939    #[test]
940    fn compile_sub_compiles_all_prop_schema_fields() {
941        // compile_sub delegates to compile_prop, so a composition branch
942        // can carry every constraint a property can.
943        let sub = compile_sub(
944            &json!({
945                "required": ["A"],
946                "properties": { "P": { "type": "string" } },
947                "additionalProperties": false,
948                "dependentRequired": { "A": ["B"] },
949                "dependentExcluded": { "C": ["D"] },
950                "type": "object",
951                "enum": ["x"],
952                "pattern": "^a",
953                "minimum": 1.0,
954                "multipleOf": 5.0,
955                "minLength": 2,
956                "anyOf": [{ "required": ["Z"] }]
957            }),
958            RefSiblings::Enforce,
959        );
960        assert_eq!(sub.required, vec!["A".to_string()]);
961        assert!(sub.properties.contains_key("P"));
962        assert_eq!(sub.additional_properties, Some(false));
963        assert_eq!(sub.dependent_required.get("A"), Some(&vec!["B".to_string()]));
964        assert_eq!(sub.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
965        assert!(sub.prop_type.is_some());
966        assert_eq!(sub.enum_values, vec![json!("x")]);
967        assert_eq!(sub.pattern.as_deref(), Some("^a"));
968        assert_eq!(sub.minimum, Some(1.0));
969        assert_eq!(sub.multiple_of, Some(5.0));
970        assert_eq!(sub.min_length, Some(2));
971        assert_eq!(sub.any_of.len(), 1);
972    }
973
974    #[test]
975    fn compile_condition_schema_fields_matches_behavior() {
976        // Every keyword listed in COMPILE_CONDITION_SCHEMA_FIELDS must produce a
977        // non-default ConditionSchema when supplied.
978        let cond = compile_condition_schema(
979            &json!({
980                "properties": { "A": { "enum": ["x"] } },
981                "required": ["A"],
982                "type": "object",
983                "anyOf": [{ "properties": { "B": { "enum": ["y"] } } }]
984            }),
985            RefSiblings::Enforce,
986        );
987        assert!(cond.properties.contains_key("A"));
988        assert_eq!(cond.required, vec!["A".to_string()]);
989        assert_eq!(cond.any_of.len(), 1);
990        assert!(cond.prop_type.is_some(), "the condition's type must be compiled");
991        assert_eq!(
992            keywords::COMPILE_CONDITION_SCHEMA_FIELDS,
993            &[keywords::PROPERTIES, keywords::REQUIRED, keywords::TYPE, keywords::ANY_OF]
994        );
995    }
996
997    #[test]
998    fn numeric_constraints_compile_into_option_f64_fields() {
999        let compiled = compile_schema(
1000            "AWS::Test::T",
1001            &json!({
1002                "properties": {
1003                    "P": {
1004                        "type": "number",
1005                        "minimum": 1.5,
1006                        "maximum": 99.9,
1007                        "exclusiveMinimum": 0.1,
1008                        "exclusiveMaximum": 100.0
1009                    }
1010                }
1011            }),
1012        );
1013        let p = &compiled.properties["P"];
1014        assert_eq!(p.minimum, Some(1.5));
1015        assert_eq!(p.maximum, Some(99.9));
1016        assert_eq!(p.exclusive_minimum, Some(0.1));
1017        assert_eq!(p.exclusive_maximum, Some(100.0));
1018    }
1019
1020    #[test]
1021    fn u64_constraints_compile_into_option_u64_fields() {
1022        let compiled = compile_schema(
1023            "AWS::Test::T",
1024            &json!({
1025                "properties": {
1026                    "P": {
1027                        "type": "string",
1028                        "minLength": 1,
1029                        "maxLength": 256
1030                    },
1031                    "A": {
1032                        "type": "array",
1033                        "minItems": 0,
1034                        "maxItems": 10
1035                    },
1036                    "O": {
1037                        "type": "object",
1038                        "minProperties": 1,
1039                        "maxProperties": 5
1040                    }
1041                }
1042            }),
1043        );
1044        assert_eq!(compiled.properties["P"].min_length, Some(1));
1045        assert_eq!(compiled.properties["P"].max_length, Some(256));
1046        assert_eq!(compiled.properties["A"].min_items, Some(0));
1047        assert_eq!(compiled.properties["A"].max_items, Some(10));
1048        assert_eq!(compiled.properties["O"].min_properties, Some(1));
1049        assert_eq!(compiled.properties["O"].max_properties, Some(5));
1050    }
1051
1052    #[test]
1053    fn string_constraints_compile_into_option_string_fields() {
1054        let compiled = compile_schema(
1055            "AWS::Test::T",
1056            &json!({
1057                "properties": {
1058                    "P": { "type": "string", "pattern": "^x$", "format": "uri", "description": "desc" }
1059                },
1060                "replacementStrategy": "delete_then_create",
1061                "documentationUrl": "https://docs",
1062                "sourceUrl": "https://src"
1063            }),
1064        );
1065        let p = &compiled.properties["P"];
1066        assert_eq!(p.pattern.as_deref(), Some("^x$"));
1067        assert_eq!(p.format.as_deref(), Some("uri"));
1068        assert_eq!(p.description.as_deref(), Some("desc"));
1069        assert_eq!(compiled.replacement_strategy.as_deref(), Some("delete_then_create"));
1070        assert_eq!(compiled.documentation_url.as_deref(), Some("https://docs"));
1071        assert_eq!(compiled.source_url.as_deref(), Some("https://src"));
1072    }
1073
1074    #[test]
1075    fn metadata_pointer_arrays_all_strip_properties_prefix() {
1076        // Confirm every keyword in METADATA_POINTER_ARRAYS is handled by
1077        // compile_schema and produces stripped dot-notation paths.
1078        let compiled = compile_schema(
1079            "AWS::Test::T",
1080            &json!({
1081                "readOnlyProperties": ["/properties/A"],
1082                "writeOnlyProperties": ["/properties/B"],
1083                "createOnlyProperties": ["/properties/C"],
1084                "deprecatedProperties": ["/properties/D"],
1085                "conditionalCreateOnlyProperties": ["/properties/E"],
1086                "primaryIdentifier": ["/properties/F"]
1087            }),
1088        );
1089        assert_eq!(compiled.read_only_properties, vec!["A"]);
1090        assert_eq!(compiled.write_only_properties, vec!["B"]);
1091        assert_eq!(compiled.create_only_properties, vec!["C"]);
1092        assert_eq!(compiled.deprecated_properties, vec!["D"]);
1093        assert_eq!(compiled.conditional_create_only_properties, vec!["E"]);
1094        assert_eq!(compiled.primary_identifier, vec!["F"]);
1095    }
1096
1097    #[test]
1098    fn definitions_ref_prefix_is_used_in_compile_prop() {
1099        let compiled = compile_schema(
1100            "AWS::Test::T",
1101            &json!({
1102                "properties": { "P": { "$ref": "#/definitions/D" } },
1103                "definitions": { "D": { "type": "object" } }
1104            }),
1105        );
1106        assert_eq!(compiled.properties["P"].ref_name.as_deref(), Some("D"));
1107    }
1108
1109    #[test]
1110    fn schema_maps_slice_matches_recursive_compilation() {
1111        // All three SCHEMA_MAPS keywords produce keyed maps when present.
1112        let compiled = compile_schema(
1113            "AWS::Test::T",
1114            &json!({
1115                "properties": { "P": { "type": "string" } },
1116                "definitions": { "D": { "type": "object" } }
1117            }),
1118        );
1119        assert!(compiled.properties.contains_key("P"));
1120        assert!(compiled.definitions.contains_key("D"));
1121        // patternProperties are compiled at the property level, not root.
1122        let compiled_prop = compile_schema(
1123            "AWS::Test::T",
1124            &json!({
1125                "properties": {
1126                    "Map": { "type": "object", "patternProperties": { "^k$": { "type": "string" } } }
1127                }
1128            }),
1129        );
1130        assert!(compiled_prop.properties["Map"].pattern_properties.contains_key("^k$"));
1131    }
1132
1133    #[test]
1134    fn ref_siblings_enforce_keeps_constraints_beside_a_ref() {
1135        let compiled = compile_schema_with(
1136            "AWS::Test::T",
1137            &json!({
1138                "properties": { "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" } },
1139                "definitions": { "D": { "type": "string" } }
1140            }),
1141            RefSiblings::Enforce,
1142        );
1143        let prop = &compiled.properties["P"];
1144        assert_eq!(prop.ref_name.as_deref(), Some("D"));
1145        assert_eq!(prop.max_length, Some(3), "Enforce must keep constraint siblings");
1146        assert_eq!(prop.pattern.as_deref(), Some("^a"));
1147    }
1148
1149    #[test]
1150    fn ref_siblings_ignore_drops_everything_beside_a_ref() {
1151        // Draft-07 - the dialect the CloudFormation registry validates with -
1152        // ignores keywords beside a `$ref`. The build pipeline compiles bundled
1153        // schemas this way so the engine never enforces more than
1154        // CloudFormation's own contract.
1155        let compiled = compile_schema_with(
1156            "AWS::Test::T",
1157            &json!({
1158                "properties": {
1159                    "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" },
1160                    "Q": { "$ref": "not-a-definitions-ref", "maxLength": 3 }
1161                },
1162                "definitions": { "D": { "type": "string" } }
1163            }),
1164            RefSiblings::Ignore,
1165        );
1166        let prop = &compiled.properties["P"];
1167        assert_eq!(prop.ref_name.as_deref(), Some("D"));
1168        assert_eq!(prop.max_length, None, "Ignore must drop constraint siblings");
1169        assert_eq!(prop.pattern, None);
1170        let unresolvable = &compiled.properties["Q"];
1171        assert_eq!(unresolvable.ref_name, None);
1172        assert_eq!(
1173            unresolvable.max_length, None,
1174            "a $ref outside definitions compiles to an unconstrained property under Ignore, matching the \
1175             historical bundled artifact"
1176        );
1177    }
1178
1179    #[test]
1180    fn condition_schema_type_is_compiled() {
1181        let cond = compile_condition_schema(&json!({ "type": "object", "required": ["A"] }), RefSiblings::Enforce);
1182        match cond.prop_type.as_ref().expect("the condition's type must be compiled") {
1183            PropType::Single(name) => assert_eq!(name, "object"),
1184            PropType::Multi(names) => panic!("expected a single type, got {names:?}"),
1185        }
1186    }
1187}