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 REPLACEMENT_STRATEGY: &str = "replacementStrategy";
179    pub const DOCUMENTATION_URL: &str = "documentationUrl";
180    pub const SOURCE_URL: &str = "sourceUrl";
181
182    /// Keywords compiled into `Option<String>` string values. `description` is
183    /// deliberately absent: it is an annotation with no validation meaning and no
184    /// reader in the validator or the engines, so it is not compiled at all (see
185    /// [`REF_ANNOTATION_KEYWORDS`]).
186    pub const STRING_CONSTRAINTS: &[&str] = &[PATTERN, FORMAT, REPLACEMENT_STRATEGY, DOCUMENTATION_URL, SOURCE_URL];
187
188    // ─── Other compiled keywords ────────────────────────────────────────────
189
190    pub const TYPE: &str = "type";
191    pub const CONST: &str = "const";
192    pub const ITEMS: &str = "items";
193    pub const TYPE_NAME: &str = "typeName";
194
195    // ─── Unrepresented validation keywords ──────────────────────────────────
196    //
197    // JSON Schema validation keywords that exist in the spec but have no field
198    // in the compiled schema model, so nothing enforces them. An overlay
199    // stating any of these would silently weaken the author's intent.
200
201    pub const UNREPRESENTED_VALIDATION_KEYWORDS: &[&str] =
202        &["propertyNames", "contains", "minContains", "maxContains", "additionalItems", "unevaluatedProperties"];
203
204    // ─── multipleOf keyword ─────────────────────────────────────────────────
205
206    pub const MULTIPLE_OF: &str = "multipleOf";
207
208    // ─── dependencies keyword ───────────────────────────────────────────────
209    //
210    // Draft-07 `dependencies` can be array-form (unioned into
211    // `dependentRequired`) or schema-form (not represented).
212
213    pub const DEPENDENCIES: &str = "dependencies";
214
215    // ─── Relationship ref keyword ───────────────────────────────────────────
216
217    pub const RELATIONSHIP_REF: &str = "relationshipRef";
218
219    // ─── Annotation keywords ────────────────────────────────────────────────
220    //
221    // Keywords that carry no validation meaning and may appear beside a `$ref`
222    // without causing an error.
223
224    pub const REF_ANNOTATION_KEYWORDS: &[&str] = &[
225        "description",
226        "markdownDescription",
227        "title",
228        "examples",
229        "default",
230        "$comment",
231        "insertionOrder",
232        RELATIONSHIP_REF,
233    ];
234
235    // ─── compile_sub fields ─────────────────────────────────────────────────
236    //
237    // `compile_sub` now compiles a composition branch into a full `PropSchema`,
238    // so it reads every constraint keyword that `compile_prop` reads. The
239    // overlay's `COMPOSITION_ALLOWED_FIELDS` is validated against what the
240    // runtime actually *evaluates* for composition entries - a separate,
241    // narrower set managed in `schema-validator`.
242
243    /// Fields read by `compile_sub`: since `SubSchema` is now `PropSchema`, this
244    /// is the full set of property-schema keywords.
245    pub const COMPILE_SUB_READS_ALL_PROP_FIELDS: bool = true;
246
247    // ─── compile_condition_schema fields ────────────────────────────────────
248    //
249    // The exact set of keywords `compile_condition_schema` reads from an `if`
250    // schema object.
251
252    /// Fields read by `compile_condition_schema`: what a conditional `if` block
253    /// may contain.
254    pub const COMPILE_CONDITION_SCHEMA_FIELDS: &[&str] = &[PROPERTIES, REQUIRED, TYPE, ALL_OF, ANY_OF, ONE_OF, NOT];
255}
256
257#[derive(Serialize, Deserialize)]
258pub struct CompiledSchema {
259    pub type_name: String,
260    #[serde(default)]
261    pub properties: BTreeMap<String, PropSchema>,
262    #[serde(default)]
263    pub definitions: BTreeMap<String, PropSchema>,
264    #[serde(default)]
265    pub required: Vec<String>,
266    #[serde(default)]
267    pub additional_properties: Option<bool>,
268    #[serde(default)]
269    pub read_only_properties: Vec<String>,
270    #[serde(default, skip_serializing_if = "Vec::is_empty")]
271    pub write_only_properties: Vec<String>,
272    #[serde(default, skip_serializing_if = "Vec::is_empty")]
273    pub create_only_properties: Vec<String>,
274    #[serde(default, skip_serializing_if = "Vec::is_empty")]
275    pub deprecated_properties: Vec<String>,
276    #[serde(default, skip_serializing_if = "Vec::is_empty")]
277    pub conditional_create_only_properties: Vec<String>,
278    #[serde(default, skip_serializing_if = "Vec::is_empty")]
279    pub primary_identifier: Vec<String>,
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub replacement_strategy: Option<String>,
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub documentation_url: Option<String>,
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub source_url: Option<String>,
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    pub all_of: Vec<SubSchema>,
288    #[serde(default, skip_serializing_if = "Vec::is_empty")]
289    pub any_of: Vec<SubSchema>,
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    pub one_of: Vec<SubSchema>,
292    #[serde(default, skip_serializing_if = "Vec::is_empty")]
293    pub if_then_else: Vec<IfThenElse>,
294    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
295    pub dependent_required: BTreeMap<String, Vec<String>>,
296    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
297    pub dependent_excluded: BTreeMap<String, Vec<String>>,
298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
299    pub required_or: Vec<String>,
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub required_xor: Vec<String>,
302}
303
304#[derive(Serialize, Deserialize, Default)]
305pub struct IfThenElse {
306    pub condition: ConditionSchema,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub then_schema: Option<SubSchema>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub else_schema: Option<SubSchema>,
311}
312
313#[derive(Serialize, Deserialize, Default)]
314pub struct ConditionSchema {
315    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
316    pub properties: BTreeMap<String, PropSchema>,
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub required: Vec<String>,
319    /// Properties the condition requires to be absent (`properties.X: false`
320    /// in JSON Schema rejects every value, so the instance can only satisfy the
321    /// condition by omitting the property).
322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
323    pub absent: Vec<String>,
324    /// The instance type the condition requires (`if: {"type": ...}`). A
325    /// condition stating a type only matches an instance of that type; resource
326    /// roots are always objects, so `"object"` is a no-op there while any other
327    /// type makes the condition unsatisfiable at the root.
328    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
329    pub prop_type: Option<PropType>,
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub all_of: Vec<ConditionSchema>,
332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
333    pub any_of: Vec<ConditionSchema>,
334    /// When non-empty, exactly one of these sub-conditions must match.
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub one_of: Vec<ConditionSchema>,
337    /// When set, the condition matches only if this sub-condition does not.
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub not: Option<Box<ConditionSchema>>,
340}
341
342#[derive(Serialize, Deserialize, Default)]
343pub struct PropSchema {
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub ref_name: Option<String>,
346    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
347    pub prop_type: Option<PropType>,
348    #[serde(default, rename = "enum", skip_serializing_if = "Vec::is_empty")]
349    pub enum_values: Vec<serde_json::Value>,
350    #[serde(default, skip_serializing_if = "Vec::is_empty")]
351    pub enum_case_insensitive: Vec<serde_json::Value>,
352    #[serde(default, skip_serializing_if = "Vec::is_empty")]
353    pub not_enum: Vec<serde_json::Value>,
354    #[serde(default, rename = "const", skip_serializing_if = "Option::is_none")]
355    pub const_value: Option<serde_json::Value>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub pattern: Option<String>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub minimum: Option<f64>,
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub maximum: Option<f64>,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub exclusive_minimum: Option<f64>,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub exclusive_maximum: Option<f64>,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub multiple_of: Option<f64>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub min_length: Option<u64>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub max_length: Option<u64>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub min_items: Option<u64>,
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub max_items: Option<u64>,
376    /// `None` when the source schema omits `uniqueItems`, so an overlay that
377    /// explicitly sets it to `false` can relax a bundled `true`. Serialization
378    /// only emits the field when it is `true`, keeping the encoding identical to
379    /// the plain-boolean form this replaced.
380    #[serde(default, skip_serializing_if = "skip_unless_true")]
381    pub unique_items: Option<bool>,
382    #[serde(default, skip_serializing_if = "Option::is_none")]
383    pub min_properties: Option<u64>,
384    #[serde(default, skip_serializing_if = "Option::is_none")]
385    pub max_properties: Option<u64>,
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub format: Option<String>,
388    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
389    pub properties: BTreeMap<String, PropSchema>,
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub required: Vec<String>,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    pub additional_properties: Option<bool>,
394    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
395    pub pattern_properties: BTreeMap<String, PropSchema>,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub items: Option<Box<PropSchema>>,
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub all_of: Vec<SubSchema>,
400    #[serde(default, skip_serializing_if = "Vec::is_empty")]
401    pub any_of: Vec<SubSchema>,
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub one_of: Vec<SubSchema>,
404    #[serde(default, skip_serializing_if = "Vec::is_empty")]
405    pub if_then_else: Vec<IfThenElse>,
406    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
407    pub dependent_required: BTreeMap<String, Vec<String>>,
408    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409    pub dependent_excluded: BTreeMap<String, Vec<String>>,
410    #[serde(default, skip_serializing_if = "Vec::is_empty")]
411    pub required_or: Vec<String>,
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub required_xor: Vec<String>,
414}
415fn skip_unless_true(value: &Option<bool>) -> bool {
416    *value != Some(true)
417}
418
419/// A composition branch is now a full property schema - every constraint that
420/// `compile_prop` produces is available in a branch and evaluated by the runtime.
421/// This alias maintains naming clarity at usage sites.
422pub type SubSchema = PropSchema;
423
424#[derive(Serialize, Deserialize)]
425#[serde(untagged)]
426pub enum PropType {
427    Single(String),
428    Multi(Vec<String>),
429}
430
431/// Convert `/properties/X/Y/Z` paths to `X.Y.Z` dot notation.
432fn convert_property_paths(raw: &serde_json::Value) -> Vec<String> {
433    raw.as_array()
434        .map(|arr| {
435            arr.iter()
436                .filter_map(|v| {
437                    v.as_str()
438                        .and_then(|s| s.strip_prefix(keywords::PROPERTIES_PATH_PREFIX))
439                        .map(|s| s.replace('/', "."))
440                })
441                .collect()
442        })
443        .unwrap_or_default()
444}
445
446/// How to treat constraint keywords written beside a `$ref`.
447///
448/// Draft-07 - the dialect provider schemas are written against, and the one the
449/// CloudFormation registry itself validates with - ignores every keyword beside
450/// a `$ref`. The build pipeline compiles bundled schemas with [`Self::Ignore`]
451/// so the engine never enforces more than CloudFormation's own contract. Overlay
452/// schemas are compiled with [`Self::Enforce`]: the author supplied the sibling
453/// constraints deliberately, and they are merged onto the referenced definition
454/// at validation time (`PropSchema::resolve`).
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum RefSiblings {
457    /// Compile constraint siblings beside a `$ref` and enforce them.
458    Enforce,
459    /// Drop everything beside a `$ref`, matching draft-07 evaluation.
460    Ignore,
461}
462
463/// Compiles a raw provider schema with [`RefSiblings::Enforce`] - the overlay
464/// path. The build pipeline uses [`compile_schema_with`] and
465/// [`RefSiblings::Ignore`] instead; see [`RefSiblings`].
466pub fn compile_schema(type_name: &str, raw: &serde_json::Value) -> CompiledSchema {
467    compile_schema_with(type_name, raw, RefSiblings::Enforce)
468}
469
470/// Compiles a raw CloudFormation resource provider schema into the compiled
471/// representation.
472///
473/// This transform is richer than the one that produced older committed
474/// `compiled_schemas.json` artifacts (full composition branches, property-level
475/// conditionals, `multipleOf`, draft-07 array-form `dependencies`), so
476/// **regenerating the committed artifact changes what bundled schemas enforce**.
477/// A regeneration must be validated against the full template corpus on both
478/// engines before it is committed.
479pub fn compile_schema_with(type_name: &str, raw: &serde_json::Value, ref_siblings: RefSiblings) -> CompiledSchema {
480    let mut defs = BTreeMap::new();
481    if let Some(d) = raw.get(keywords::DEFINITIONS).and_then(|v| v.as_object()) {
482        for (k, v) in d {
483            defs.insert(k.clone(), compile_prop_with(v, ref_siblings));
484        }
485    }
486    let mut props = BTreeMap::new();
487    if let Some(p) = raw.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
488        for (k, v) in p {
489            props.insert(k.clone(), compile_prop_with(v, ref_siblings));
490        }
491    }
492
493    let mut all_of = Vec::new();
494    let mut if_then_else = Vec::new();
495    if let Some(arr) = raw.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
496        for item in arr {
497            if item.get(keywords::IF).is_some() {
498                if let Some(ite) = compile_if_then_else(item, ref_siblings) {
499                    if_then_else.push(ite);
500                }
501            } else if let Some(exclusion) = compile_negated_property_exclusion(item, ref_siblings) {
502                if_then_else.push(exclusion);
503            } else {
504                all_of.push(compile_sub(item, ref_siblings));
505            }
506        }
507    }
508
509    CompiledSchema {
510        type_name: type_name.to_string(),
511        properties: props,
512        definitions: defs,
513        required: str_arr(raw.get(keywords::REQUIRED)),
514        additional_properties: raw.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
515        read_only_properties: convert_property_paths(
516            raw.get(keywords::READ_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
517        ),
518        write_only_properties: convert_property_paths(
519            raw.get(keywords::WRITE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
520        ),
521        create_only_properties: convert_property_paths(
522            raw.get(keywords::CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
523        ),
524        deprecated_properties: convert_property_paths(
525            raw.get(keywords::DEPRECATED_PROPERTIES).unwrap_or(&serde_json::Value::Null),
526        ),
527        conditional_create_only_properties: convert_property_paths(
528            raw.get(keywords::CONDITIONAL_CREATE_ONLY_PROPERTIES).unwrap_or(&serde_json::Value::Null),
529        ),
530        primary_identifier: convert_property_paths(
531            raw.get(keywords::PRIMARY_IDENTIFIER).unwrap_or(&serde_json::Value::Null),
532        ),
533        replacement_strategy: raw
534            .get(keywords::REPLACEMENT_STRATEGY)
535            .and_then(|v| v.as_str())
536            .filter(|s| !s.is_empty())
537            .map(String::from),
538        documentation_url: raw
539            .get(keywords::DOCUMENTATION_URL)
540            .and_then(|v| v.as_str())
541            .filter(|s| !s.is_empty())
542            .map(String::from),
543        source_url: raw.get(keywords::SOURCE_URL).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
544        all_of,
545        any_of: compile_subs(raw.get(keywords::ANY_OF), ref_siblings),
546        one_of: compile_subs(raw.get(keywords::ONE_OF), ref_siblings),
547        if_then_else,
548        dependent_required: compile_dependent_required(raw),
549        dependent_excluded: str_map(raw.get(keywords::DEPENDENT_EXCLUDED)),
550        required_or: str_arr(raw.get(keywords::REQUIRED_OR)),
551        required_xor: str_arr(raw.get(keywords::REQUIRED_XOR)),
552    }
553}
554
555/// Compile `dependentRequired` and draft-07 `dependencies` (array-form) into a
556/// unified dependent_required map.
557fn compile_dependent_required(raw: &serde_json::Value) -> BTreeMap<String, Vec<String>> {
558    let mut dep_req = str_map(raw.get(keywords::DEPENDENT_REQUIRED));
559    if let Some(deps_obj) = raw.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
560        for (trigger, value) in deps_obj {
561            if let Some(arr) = value.as_array() {
562                let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
563                if !names.is_empty() {
564                    dep_req.entry(trigger.clone()).or_default().extend(names);
565                }
566            }
567        }
568    }
569    for deps in dep_req.values_mut() {
570        deps.sort();
571        deps.dedup();
572    }
573    dep_req
574}
575
576fn compile_negated_property_exclusion(raw: &serde_json::Value, ref_siblings: RefSiblings) -> Option<IfThenElse> {
577    let branch = raw.as_object()?;
578    if branch.keys().any(|key| key != keywords::NOT && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())) {
579        return None;
580    }
581
582    let negated_schema = branch.get(keywords::NOT)?;
583    let negated_members = negated_schema.as_object()?;
584    if negated_members.keys().any(|key| {
585        !matches!(key.as_str(), keywords::PROPERTIES | keywords::REQUIRED | keywords::TYPE)
586            && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())
587    }) {
588        return None;
589    }
590
591    let excluded_properties = str_arr(negated_members.get(keywords::REQUIRED));
592    if excluded_properties.len() != 2 {
593        return None;
594    }
595    if let Some(property_constraints) = negated_members.get(keywords::PROPERTIES).and_then(|value| value.as_object())
596        && property_constraints.keys().any(|property| !excluded_properties.contains(property))
597    {
598        return None;
599    }
600
601    let mut dependent_excluded = BTreeMap::new();
602    dependent_excluded.insert(excluded_properties[0].clone(), vec![excluded_properties[1].clone()]);
603    dependent_excluded.insert(excluded_properties[1].clone(), vec![excluded_properties[0].clone()]);
604
605    Some(IfThenElse {
606        condition: compile_condition_schema(negated_schema, ref_siblings),
607        then_schema: Some(SubSchema { dependent_excluded, ..Default::default() }),
608        else_schema: None,
609    })
610}
611
612fn compile_if_then_else(raw: &serde_json::Value, ref_siblings: RefSiblings) -> Option<IfThenElse> {
613    let if_val = raw.get(keywords::IF)?;
614    let condition = compile_condition_schema(if_val, ref_siblings);
615    let then_schema = raw.get(keywords::THEN).map(|branch| compile_sub(branch, ref_siblings));
616    let else_schema = raw.get(keywords::ELSE).map(|branch| compile_sub(branch, ref_siblings));
617    if then_schema.is_none() && else_schema.is_none() {
618        return None;
619    }
620    Some(IfThenElse { condition, then_schema, else_schema })
621}
622
623/// Compile the JSON Schema predicate used by a conditional extension.
624///
625/// Callers choose how `$ref` siblings are handled because bundled schemas use
626/// draft-07 semantics while caller-provided overlays enforce their siblings.
627pub fn compile_condition_schema(raw: &serde_json::Value, ref_siblings: RefSiblings) -> ConditionSchema {
628    let obj = match raw.as_object() {
629        Some(o) => o,
630        None => return ConditionSchema::default(),
631    };
632    let mut props = BTreeMap::new();
633    let mut absent = Vec::new();
634    if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
635        for (k, v) in p {
636            if v == &serde_json::Value::Bool(false) {
637                absent.push(k.clone());
638            } else {
639                props.insert(k.clone(), compile_prop_with(v, ref_siblings));
640            }
641        }
642    }
643    let compile_sub_conditions = |keyword: &str| -> Vec<ConditionSchema> {
644        obj.get(keyword)
645            .and_then(|v| v.as_array())
646            .map(|arr| arr.iter().map(|entry| compile_condition_schema(entry, ref_siblings)).collect())
647            .unwrap_or_default()
648    };
649    let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
650    ConditionSchema {
651        properties: props,
652        required: str_arr(obj.get(keywords::REQUIRED)),
653        absent,
654        prop_type,
655        all_of: compile_sub_conditions(keywords::ALL_OF),
656        any_of: compile_sub_conditions(keywords::ANY_OF),
657        one_of: compile_sub_conditions(keywords::ONE_OF),
658        not: obj.get(keywords::NOT).map(|negated| Box::new(compile_condition_schema(negated, ref_siblings))),
659    }
660}
661
662/// Describes every resource-root conditional whose `if` schema the compiler
663/// cannot represent faithfully: a keyword outside
664/// [`keywords::COMPILE_CONDITION_SCHEMA_FIELDS`], or an `if` that states no
665/// condition at all. Such a condition would compile to one that always (or
666/// never) matches, silently changing what the `then` branch applies to, so the
667/// build pipeline refuses to emit it. Sub-conditions under `allOf`, `anyOf`,
668/// `oneOf`, and `not` follow the same grammar and are audited recursively.
669pub fn unsupported_root_conditionals(type_name: &str, raw: &serde_json::Value) -> Vec<String> {
670    let Some(all_of) = raw.get(keywords::ALL_OF).and_then(|v| v.as_array()) else {
671        return Vec::new();
672    };
673    let mut findings = Vec::new();
674    for (index, entry) in all_of.iter().enumerate() {
675        if let Some(condition) = entry.get(keywords::IF) {
676            audit_condition(condition, &format!("{type_name} allOf[{index}].if"), &mut findings);
677        }
678    }
679    findings
680}
681
682fn audit_condition(condition: &serde_json::Value, location: &str, findings: &mut Vec<String>) {
683    let Some(members) = condition.as_object() else {
684        findings.push(format!("{location} must be a JSON object"));
685        return;
686    };
687    let unsupported: Vec<&str> = members
688        .keys()
689        .map(String::as_str)
690        .filter(|key| {
691            !keywords::COMPILE_CONDITION_SCHEMA_FIELDS.contains(key) && !keywords::REF_ANNOTATION_KEYWORDS.contains(key)
692        })
693        .collect();
694    if !unsupported.is_empty() {
695        findings.push(format!("{location} uses keywords the condition compiler does not represent: {unsupported:?}"));
696    }
697    let states_a_condition =
698        members.keys().any(|key| keywords::COMPILE_CONDITION_SCHEMA_FIELDS.contains(&key.as_str()));
699    if !states_a_condition {
700        findings.push(format!("{location} states no condition"));
701    }
702    for keyword in [keywords::ALL_OF, keywords::ANY_OF, keywords::ONE_OF] {
703        if let Some(sub_conditions) = members.get(keyword).and_then(|v| v.as_array()) {
704            for (index, sub_condition) in sub_conditions.iter().enumerate() {
705                audit_condition(sub_condition, &format!("{location}.{keyword}[{index}]"), findings);
706            }
707        }
708    }
709    if let Some(negated) = members.get(keywords::NOT) {
710        audit_condition(negated, &format!("{location}.not"), findings);
711    }
712}
713
714/// Compiles a raw `type` keyword value into the [`PropType`] representation:
715/// a single name, an array of names, or (for a malformed value the preflight
716/// rejects on overlay input) the historical `"string"` fallback.
717fn compile_prop_type(raw: &serde_json::Value) -> PropType {
718    match raw {
719        serde_json::Value::String(s) => PropType::Single(s.clone()),
720        serde_json::Value::Array(a) => PropType::Multi(a.iter().filter_map(|v| v.as_str().map(String::from)).collect()),
721        _ => PropType::Single("string".into()),
722    }
723}
724
725fn compile_prop_with(raw: &serde_json::Value, ref_siblings: RefSiblings) -> PropSchema {
726    let obj = match raw.as_object() {
727        Some(o) => o,
728        None => return PropSchema::default(),
729    };
730    // When a $ref is present, compile it as the ref_name. Under
731    // [`RefSiblings::Enforce`], represented sibling constraints beside the
732    // reference are additionally compiled and merged at validation time via
733    // `PropSchema::resolve`. Under [`RefSiblings::Ignore`] - draft-07
734    // evaluation, used for bundled schemas - everything beside the reference is
735    // dropped. Annotations beside a $ref are ignored either way.
736    let raw_ref = obj.get(keywords::REF).and_then(|v| v.as_str());
737    let ref_name = raw_ref.and_then(|ref_str| ref_str.strip_prefix(keywords::DEFINITIONS_REF_PREFIX).map(String::from));
738    if raw_ref.is_some() && ref_siblings == RefSiblings::Ignore {
739        return PropSchema { ref_name, ..Default::default() };
740    }
741    // If the property is $ref-only (no other constraint keywords), return early.
742    if ref_name.is_some() {
743        let has_constraint_siblings = obj.keys().any(|key| {
744            key != keywords::REF
745                && !keywords::REF_ANNOTATION_KEYWORDS.contains(&key.as_str())
746                && key != keywords::RELATIONSHIP_REF
747        });
748        if !has_constraint_siblings {
749            return PropSchema { ref_name, ..Default::default() };
750        }
751    }
752
753    let prop_type = obj.get(keywords::TYPE).map(compile_prop_type);
754    let mut sub_props = BTreeMap::new();
755    if let Some(p) = obj.get(keywords::PROPERTIES).and_then(|v| v.as_object()) {
756        for (k, v) in p {
757            sub_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
758        }
759    }
760    let mut pat_props = BTreeMap::new();
761    if let Some(p) = obj.get(keywords::PATTERN_PROPERTIES).and_then(|v| v.as_object()) {
762        for (k, v) in p {
763            pat_props.insert(k.clone(), compile_prop_with(v, ref_siblings));
764        }
765    }
766    let items = obj.get(keywords::ITEMS).map(|v| Box::new(compile_prop_with(v, ref_siblings)));
767
768    // Compile draft-07 `dependencies` array-form into dependent_required.
769    let mut dep_req = str_map(obj.get(keywords::DEPENDENT_REQUIRED).cloned().as_ref());
770    if let Some(deps_obj) = obj.get(keywords::DEPENDENCIES).and_then(|v| v.as_object()) {
771        for (trigger, value) in deps_obj {
772            if let Some(arr) = value.as_array() {
773                let names: Vec<String> = arr.iter().filter_map(|v| v.as_str().map(String::from)).collect();
774                if !names.is_empty() {
775                    dep_req.entry(trigger.clone()).or_default().extend(names);
776                }
777            }
778            // Schema-form dependencies are not represented; they are handled by
779            // the overlay preflight rejection path.
780        }
781    }
782    // Deduplicate dependency arrays.
783    for deps in dep_req.values_mut() {
784        deps.sort();
785        deps.dedup();
786    }
787
788    // Compile direct if/then/else at property level.
789    let mut if_then_else = Vec::new();
790    if obj.get(keywords::IF).is_some()
791        && let Some(ite) = compile_if_then_else(raw, ref_siblings)
792    {
793        if_then_else.push(ite);
794    }
795
796    // Compile allOf, splitting conditionals.
797    let mut all_of_branches = Vec::new();
798    if let Some(arr) = obj.get(keywords::ALL_OF).and_then(|v| v.as_array()) {
799        for item in arr {
800            if item.get(keywords::IF).is_some() {
801                if let Some(ite) = compile_if_then_else(item, ref_siblings) {
802                    if_then_else.push(ite);
803                }
804            } else {
805                all_of_branches.push(compile_sub(item, ref_siblings));
806            }
807        }
808    }
809
810    let not_enum = obj
811        .get(keywords::NOT)
812        .and_then(|value| value.as_object())
813        .map(|negated| {
814            negated
815                .get(keywords::ENUM)
816                .and_then(|value| value.as_array())
817                .cloned()
818                .or_else(|| negated.get(keywords::CONST).cloned().map(|value| vec![value]))
819                .unwrap_or_default()
820        })
821        .unwrap_or_default();
822
823    PropSchema {
824        ref_name,
825        prop_type,
826        enum_values: obj.get(keywords::ENUM).and_then(|v| v.as_array()).cloned().unwrap_or_default(),
827        enum_case_insensitive: obj
828            .get(keywords::ENUM_CASE_INSENSITIVE)
829            .and_then(|v| v.as_array())
830            .cloned()
831            .unwrap_or_default(),
832        not_enum,
833        const_value: obj.get(keywords::CONST).cloned(),
834        pattern: obj.get(keywords::PATTERN).and_then(|v| v.as_str()).map(String::from),
835        minimum: obj.get(keywords::MINIMUM).and_then(|v| v.as_f64()),
836        maximum: obj.get(keywords::MAXIMUM).and_then(|v| v.as_f64()),
837        exclusive_minimum: obj.get(keywords::EXCLUSIVE_MINIMUM).and_then(|v| v.as_f64()),
838        exclusive_maximum: obj.get(keywords::EXCLUSIVE_MAXIMUM).and_then(|v| v.as_f64()),
839        multiple_of: obj.get(keywords::MULTIPLE_OF).and_then(|v| v.as_f64()),
840        min_length: obj.get(keywords::MIN_LENGTH).and_then(|v| v.as_u64()),
841        max_length: obj.get(keywords::MAX_LENGTH).and_then(|v| v.as_u64()),
842        min_items: obj.get(keywords::MIN_ITEMS).and_then(|v| v.as_u64()),
843        max_items: obj.get(keywords::MAX_ITEMS).and_then(|v| v.as_u64()),
844        unique_items: obj.get(keywords::UNIQUE_ITEMS).and_then(|v| v.as_bool()),
845        min_properties: obj.get(keywords::MIN_PROPERTIES).and_then(|v| v.as_u64()),
846        max_properties: obj.get(keywords::MAX_PROPERTIES).and_then(|v| v.as_u64()),
847        format: obj.get(keywords::FORMAT).and_then(|v| v.as_str()).map(String::from),
848        properties: sub_props,
849        required: str_arr(obj.get(keywords::REQUIRED).cloned().as_ref()),
850        additional_properties: obj.get(keywords::ADDITIONAL_PROPERTIES).and_then(|v| v.as_bool()),
851        pattern_properties: pat_props,
852        items,
853        all_of: all_of_branches,
854        any_of: compile_subs(obj.get(keywords::ANY_OF).cloned().as_ref(), ref_siblings),
855        one_of: compile_subs(obj.get(keywords::ONE_OF).cloned().as_ref(), ref_siblings),
856        if_then_else,
857        dependent_required: dep_req,
858        dependent_excluded: str_map(obj.get(keywords::DEPENDENT_EXCLUDED).cloned().as_ref()),
859        required_or: str_arr(obj.get(keywords::REQUIRED_OR).cloned().as_ref()),
860        required_xor: str_arr(obj.get(keywords::REQUIRED_XOR).cloned().as_ref()),
861    }
862}
863
864fn compile_sub(raw: &serde_json::Value, ref_siblings: RefSiblings) -> SubSchema {
865    compile_prop_with(raw, ref_siblings)
866}
867
868fn compile_subs(val: Option<&serde_json::Value>, ref_siblings: RefSiblings) -> Vec<SubSchema> {
869    let Some(arr) = val.and_then(|v| v.as_array()) else {
870        return Vec::new();
871    };
872    arr.iter().map(|entry| compile_sub(entry, ref_siblings)).collect()
873}
874
875fn str_arr(val: Option<&serde_json::Value>) -> Vec<String> {
876    val.and_then(|v| v.as_array())
877        .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
878        .unwrap_or_default()
879}
880
881fn str_map(val: Option<&serde_json::Value>) -> BTreeMap<String, Vec<String>> {
882    val.and_then(|v| v.as_object())
883        .map(|m| m.iter().map(|(k, v)| (k.clone(), str_arr(Some(v)))).collect())
884        .unwrap_or_default()
885}
886
887#[cfg(test)]
888mod tests {
889    use super::*;
890    use serde_json::json;
891
892    #[test]
893    fn compiles_properties_definitions_and_refs() {
894        let compiled = compile_schema(
895            "AWS::Test::T",
896            &json!({
897                "properties": {
898                    "Name": { "type": "string", "pattern": "^a", "minLength": 1 },
899                    "Cfg": { "$ref": "#/definitions/Config" },
900                    "Kinds": { "type": ["string", "null"] }
901                },
902                "definitions": {
903                    "Config": {
904                        "type": "object",
905                        "required": ["Inner"],
906                        "requiredOr": ["First", "Second"],
907                        "requiredXor": ["Left", "Right"]
908                    }
909                },
910                "required": ["Name"],
911                "additionalProperties": false
912            }),
913        );
914        assert_eq!(compiled.type_name, "AWS::Test::T");
915        assert_eq!(compiled.required, vec!["Name".to_string()]);
916        assert_eq!(compiled.additional_properties, Some(false));
917        assert_eq!(compiled.properties["Cfg"].ref_name.as_deref(), Some("Config"));
918        assert_eq!(compiled.definitions["Config"].required, vec!["Inner".to_string()]);
919        assert_eq!(compiled.definitions["Config"].required_or, vec!["First".to_string(), "Second".to_string()]);
920        assert_eq!(compiled.definitions["Config"].required_xor, vec!["Left".to_string(), "Right".to_string()]);
921        assert_eq!(compiled.properties["Name"].pattern.as_deref(), Some("^a"));
922        assert_eq!(compiled.properties["Name"].min_length, Some(1));
923        match compiled.properties["Kinds"].prop_type.as_ref().expect("a multi type is compiled") {
924            PropType::Multi(names) => assert_eq!(names, &vec!["string".to_string(), "null".to_string()]),
925            PropType::Single(name) => panic!("expected a multi type, got {name}"),
926        }
927    }
928
929    #[test]
930    fn converts_property_pointer_paths_to_dot_notation() {
931        let compiled = compile_schema(
932            "AWS::Test::T",
933            &json!({
934                "readOnlyProperties": ["/properties/Arn", "/properties/Nested/Id"],
935                "writeOnlyProperties": ["/properties/Secret"],
936                "createOnlyProperties": ["/properties/Name"],
937                "deprecatedProperties": ["/properties/Old"],
938                "conditionalCreateOnlyProperties": ["/properties/Maybe"],
939                "primaryIdentifier": ["/properties/Name"]
940            }),
941        );
942        assert_eq!(compiled.read_only_properties, vec!["Arn".to_string(), "Nested.Id".to_string()]);
943        assert_eq!(compiled.write_only_properties, vec!["Secret".to_string()]);
944        assert_eq!(compiled.create_only_properties, vec!["Name".to_string()]);
945        assert_eq!(compiled.deprecated_properties, vec!["Old".to_string()]);
946        assert_eq!(compiled.conditional_create_only_properties, vec!["Maybe".to_string()]);
947        assert_eq!(compiled.primary_identifier, vec!["Name".to_string()]);
948    }
949
950    #[test]
951    fn splits_all_of_into_plain_and_conditional_entries() {
952        let compiled = compile_schema(
953            "AWS::Test::T",
954            &json!({
955                "allOf": [
956                    { "required": ["Plain"] },
957                    { "if": { "properties": { "A": { "enum": ["x"] } } }, "then": { "required": ["B"] } },
958                    { "if": { "properties": { "A": { "enum": ["y"] } } } }
959                ]
960            }),
961        );
962        assert_eq!(compiled.all_of.len(), 1, "plain entries stay in all_of");
963        assert_eq!(compiled.all_of[0].required, vec!["Plain".to_string()]);
964        assert_eq!(compiled.if_then_else.len(), 1, "an if with neither then nor else is dropped");
965        assert_eq!(compiled.if_then_else[0].then_schema.as_ref().expect("then branch").required, vec!["B".to_string()]);
966    }
967
968    #[test]
969    fn preserves_unique_items_presence() {
970        let compiled = compile_schema(
971            "AWS::Test::T",
972            &json!({
973                "properties": {
974                    "Strict": { "type": "array", "uniqueItems": true },
975                    "Relaxed": { "type": "array", "uniqueItems": false },
976                    "Silent": { "type": "array" }
977                }
978            }),
979        );
980        assert_eq!(compiled.properties["Strict"].unique_items, Some(true));
981        assert_eq!(
982            compiled.properties["Relaxed"].unique_items,
983            Some(false),
984            "an explicit false must be distinguishable from an omitted keyword"
985        );
986        assert_eq!(compiled.properties["Silent"].unique_items, None);
987    }
988
989    #[test]
990    fn unique_items_serialization_only_emits_true() {
991        // The committed `compiled_schemas.json` was produced when this field was a
992        // plain bool that was skipped unless true; the encoding must not change.
993        let compiled = compile_schema(
994            "AWS::Test::T",
995            &json!({
996                "properties": {
997                    "Strict": { "uniqueItems": true },
998                    "Relaxed": { "uniqueItems": false },
999                    "Silent": {}
1000                }
1001            }),
1002        );
1003        let json = serde_json::to_value(&compiled).expect("a compiled schema serializes");
1004        let properties = json.get("properties").and_then(|v| v.as_object()).expect("properties are serialized");
1005        assert_eq!(properties["Strict"].get("unique_items"), Some(&json!(true)));
1006        assert!(properties["Relaxed"].get("unique_items").is_none(), "explicit false must be omitted");
1007        assert!(properties["Silent"].get("unique_items").is_none(), "an absent keyword must be omitted");
1008    }
1009
1010    #[test]
1011    fn compiles_both_enum_representations_and_constraint_keywords() {
1012        let compiled = compile_schema(
1013            "AWS::Test::T",
1014            &json!({
1015                "properties": {
1016                    "Exact": { "enum": ["a", "b"] },
1017                    "Insensitive": { "enumCaseInsensitive": ["a", "b"] },
1018                    "Excluded": { "not": { "enum": ["bad"] } },
1019                    "ExcludedConst": { "not": { "const": "bad" } },
1020                    "Fixed": { "const": 7 }
1021                },
1022                "dependentRequired": { "A": ["B"] },
1023                "dependentExcluded": { "C": ["D"] },
1024                "requiredOr": ["A", "B"],
1025                "requiredXor": ["C", "D"]
1026            }),
1027        );
1028        assert_eq!(compiled.properties["Exact"].enum_values, vec![json!("a"), json!("b")]);
1029        assert_eq!(compiled.properties["Insensitive"].enum_case_insensitive, vec![json!("a"), json!("b")]);
1030        assert_eq!(compiled.properties["Excluded"].not_enum, vec![json!("bad")]);
1031        assert_eq!(compiled.properties["ExcludedConst"].not_enum, vec![json!("bad")]);
1032        assert_eq!(compiled.properties["Fixed"].const_value, Some(json!(7)));
1033        assert_eq!(compiled.dependent_required.get("A"), Some(&vec!["B".to_string()]));
1034        assert_eq!(compiled.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
1035        assert_eq!(compiled.required_or, vec!["A".to_string(), "B".to_string()]);
1036        assert_eq!(compiled.required_xor, vec!["C".to_string(), "D".to_string()]);
1037    }
1038
1039    #[test]
1040    fn compiles_nested_properties_items_and_pattern_properties() {
1041        let compiled = compile_schema(
1042            "AWS::Test::T",
1043            &json!({
1044                "properties": {
1045                    "Cfg": { "type": "object", "properties": { "Inner": { "type": "string" } } },
1046                    "Arr": { "type": "array", "items": { "type": "string", "maxLength": 3 } },
1047                    "Map": { "type": "object", "patternProperties": { "^k$": { "type": "integer" } } }
1048                }
1049            }),
1050        );
1051        assert!(compiled.properties["Cfg"].properties["Inner"].prop_type.is_some(), "nested type is compiled");
1052        assert_eq!(compiled.properties["Arr"].items.as_ref().expect("items").max_length, Some(3));
1053        assert!(compiled.properties["Map"].pattern_properties.contains_key("^k$"));
1054    }
1055
1056    // ─── Vocabulary pinning tests ───────────────────────────────────────────
1057
1058    #[test]
1059    fn compile_sub_compiles_all_prop_schema_fields() {
1060        // compile_sub delegates to compile_prop, so a composition branch
1061        // can carry every constraint a property can.
1062        let sub = compile_sub(
1063            &json!({
1064                "required": ["A"],
1065                "properties": { "P": { "type": "string" } },
1066                "additionalProperties": false,
1067                "dependentRequired": { "A": ["B"] },
1068                "dependentExcluded": { "C": ["D"] },
1069                "type": "object",
1070                "enum": ["x"],
1071                "pattern": "^a",
1072                "minimum": 1.0,
1073                "multipleOf": 5.0,
1074                "minLength": 2,
1075                "anyOf": [{ "required": ["Z"] }]
1076            }),
1077            RefSiblings::Enforce,
1078        );
1079        assert_eq!(sub.required, vec!["A".to_string()]);
1080        assert!(sub.properties.contains_key("P"));
1081        assert_eq!(sub.additional_properties, Some(false));
1082        assert_eq!(sub.dependent_required.get("A"), Some(&vec!["B".to_string()]));
1083        assert_eq!(sub.dependent_excluded.get("C"), Some(&vec!["D".to_string()]));
1084        assert!(sub.prop_type.is_some());
1085        assert_eq!(sub.enum_values, vec![json!("x")]);
1086        assert_eq!(sub.pattern.as_deref(), Some("^a"));
1087        assert_eq!(sub.minimum, Some(1.0));
1088        assert_eq!(sub.multiple_of, Some(5.0));
1089        assert_eq!(sub.min_length, Some(2));
1090        assert_eq!(sub.any_of.len(), 1);
1091    }
1092
1093    #[test]
1094    fn compile_condition_schema_fields_matches_behavior() {
1095        // Every keyword listed in COMPILE_CONDITION_SCHEMA_FIELDS must produce a
1096        // non-default ConditionSchema when supplied.
1097        let cond = compile_condition_schema(
1098            &json!({
1099                "properties": { "A": { "enum": ["x"] } },
1100                "required": ["A"],
1101                "type": "object",
1102                "allOf": [{ "required": ["E"] }],
1103                "anyOf": [{ "properties": { "B": { "enum": ["y"] } } }],
1104                "oneOf": [{ "required": ["C"] }, { "properties": { "C": false } }],
1105                "not": { "required": ["D"] }
1106            }),
1107            RefSiblings::Enforce,
1108        );
1109        assert!(cond.properties.contains_key("A"));
1110        assert_eq!(cond.required, vec!["A".to_string()]);
1111        assert_eq!(cond.all_of.len(), 1);
1112        assert_eq!(cond.any_of.len(), 1);
1113        assert_eq!(cond.one_of.len(), 2);
1114        assert_eq!(
1115            cond.one_of[1].absent,
1116            vec!["C".to_string()],
1117            "a `false` property schema means the property is absent"
1118        );
1119        assert_eq!(cond.not.as_ref().map(|negated| negated.required.clone()), Some(vec!["D".to_string()]));
1120        assert!(cond.prop_type.is_some(), "the condition's type must be compiled");
1121        assert_eq!(
1122            keywords::COMPILE_CONDITION_SCHEMA_FIELDS,
1123            &[
1124                keywords::PROPERTIES,
1125                keywords::REQUIRED,
1126                keywords::TYPE,
1127                keywords::ALL_OF,
1128                keywords::ANY_OF,
1129                keywords::ONE_OF,
1130                keywords::NOT
1131            ]
1132        );
1133    }
1134
1135    #[test]
1136    fn negated_property_pair_compiles_to_conditional_exclusions() {
1137        let compiled = compile_schema(
1138            "AWS::Test::NetworkInterface",
1139            &json!({
1140                "properties": {
1141                    "Ipv6AddressCount": { "type": "integer" },
1142                    "Ipv6Addresses": { "type": "array" }
1143                },
1144                "allOf": [{
1145                    "not": {
1146                        "properties": {
1147                            "Ipv6AddressCount": { "minimum": 1, "type": "integer" },
1148                            "Ipv6Addresses": { "minItems": 1, "type": "array" }
1149                        },
1150                        "required": ["Ipv6AddressCount", "Ipv6Addresses"]
1151                    }
1152                }]
1153            }),
1154        );
1155
1156        assert!(compiled.all_of.is_empty(), "the negated pair must not compile to an empty allOf branch");
1157        assert_eq!(compiled.if_then_else.len(), 1);
1158        let conditional = &compiled.if_then_else[0];
1159        assert_eq!(conditional.condition.required, vec!["Ipv6AddressCount".to_string(), "Ipv6Addresses".to_string()]);
1160        assert_eq!(conditional.condition.properties["Ipv6AddressCount"].minimum, Some(1.0));
1161        assert_eq!(conditional.condition.properties["Ipv6Addresses"].min_items, Some(1));
1162        let exclusions = &conditional.then_schema.as_ref().expect("the matching pair is excluded").dependent_excluded;
1163        assert_eq!(exclusions["Ipv6AddressCount"], vec!["Ipv6Addresses".to_string()]);
1164        assert_eq!(exclusions["Ipv6Addresses"], vec!["Ipv6AddressCount".to_string()]);
1165
1166        let serialized = serde_json::to_string(&compiled).expect("the compiled schema serializes");
1167        assert!(!serialized.contains(r#""all_of":[{}]"#));
1168    }
1169
1170    #[test]
1171    fn negated_and_exclusive_conditions_survive_compilation() {
1172        let compiled = compile_schema(
1173            "AWS::Test::Domain",
1174            &json!({
1175                "properties": { "Domain": { "type": "string" }, "CustomDomainConfig": { "type": "object" } },
1176                "allOf": [
1177                    {
1178                        "if": { "not": { "required": ["CustomDomainConfig"] } },
1179                        "then": { "properties": { "Domain": { "pattern": "^[a-z0-9-]+$" } } }
1180                    },
1181                    {
1182                        "if": { "oneOf": [
1183                            { "properties": { "Type": { "const": "application" } }, "required": ["Type"] },
1184                            { "properties": { "Type": false } }
1185                        ] },
1186                        "then": { "properties": { "Subnets": { "minItems": 2 } } }
1187                    }
1188                ]
1189            }),
1190        );
1191        let negated = compiled.if_then_else[0].condition.not.as_ref().expect("`not` is compiled");
1192        assert_eq!(negated.required, vec!["CustomDomainConfig".to_string()]);
1193        let exclusive = &compiled.if_then_else[1].condition.one_of;
1194        assert_eq!(exclusive.len(), 2);
1195        assert!(exclusive[0].properties.contains_key("Type"));
1196        assert_eq!(exclusive[1].absent, vec!["Type".to_string()]);
1197    }
1198
1199    #[test]
1200    fn root_conditional_audit_accepts_the_supported_grammar() {
1201        let schema = json!({
1202            "allOf": [
1203                { "if": { "allOf": [{ "required": ["A"] }, { "not": { "required": ["B"] } }] }, "then": {} },
1204                { "if": { "required": ["A"] }, "then": {} },
1205                { "if": { "not": { "required": ["B"] } }, "then": {} },
1206                { "if": { "oneOf": [{ "properties": { "T": { "const": "x" } }, "required": ["T"] }, { "properties": { "T": false } }] }, "then": {} },
1207                { "if": { "anyOf": [{ "required": ["C"] }], "description": "annotation" }, "then": {} },
1208                { "properties": { "Plain": { "type": "string" } } }
1209            ]
1210        });
1211        assert!(unsupported_root_conditionals("AWS::Test::T", &schema).is_empty());
1212    }
1213
1214    #[test]
1215    fn root_conditional_audit_rejects_unrepresented_keywords_and_empty_conditions() {
1216        let schema = json!({
1217            "allOf": [
1218                { "if": { "minimum": 1 }, "then": {} },
1219                { "if": { "description": "says nothing" }, "then": {} },
1220                { "if": { "oneOf": [{ "maximum": 60 }] }, "then": {} }
1221            ]
1222        });
1223        let findings = unsupported_root_conditionals("AWS::Test::T", &schema);
1224        assert_eq!(findings.len(), 5, "{findings:?}");
1225        assert!(findings[0].contains("allOf[0].if") && findings[0].contains("\"minimum\""));
1226        assert!(findings[1].contains("allOf[0].if states no condition"));
1227        assert!(findings[2].contains("allOf[1].if states no condition"));
1228        assert!(findings[3].contains("allOf[2].if.oneOf[0]") && findings[3].contains("\"maximum\""));
1229        assert!(findings[4].contains("allOf[2].if.oneOf[0] states no condition"));
1230    }
1231
1232    #[test]
1233    fn numeric_constraints_compile_into_option_f64_fields() {
1234        let compiled = compile_schema(
1235            "AWS::Test::T",
1236            &json!({
1237                "properties": {
1238                    "P": {
1239                        "type": "number",
1240                        "minimum": 1.5,
1241                        "maximum": 99.9,
1242                        "exclusiveMinimum": 0.1,
1243                        "exclusiveMaximum": 100.0
1244                    }
1245                }
1246            }),
1247        );
1248        let p = &compiled.properties["P"];
1249        assert_eq!(p.minimum, Some(1.5));
1250        assert_eq!(p.maximum, Some(99.9));
1251        assert_eq!(p.exclusive_minimum, Some(0.1));
1252        assert_eq!(p.exclusive_maximum, Some(100.0));
1253    }
1254
1255    #[test]
1256    fn u64_constraints_compile_into_option_u64_fields() {
1257        let compiled = compile_schema(
1258            "AWS::Test::T",
1259            &json!({
1260                "properties": {
1261                    "P": {
1262                        "type": "string",
1263                        "minLength": 1,
1264                        "maxLength": 256
1265                    },
1266                    "A": {
1267                        "type": "array",
1268                        "minItems": 0,
1269                        "maxItems": 10
1270                    },
1271                    "O": {
1272                        "type": "object",
1273                        "minProperties": 1,
1274                        "maxProperties": 5
1275                    }
1276                }
1277            }),
1278        );
1279        assert_eq!(compiled.properties["P"].min_length, Some(1));
1280        assert_eq!(compiled.properties["P"].max_length, Some(256));
1281        assert_eq!(compiled.properties["A"].min_items, Some(0));
1282        assert_eq!(compiled.properties["A"].max_items, Some(10));
1283        assert_eq!(compiled.properties["O"].min_properties, Some(1));
1284        assert_eq!(compiled.properties["O"].max_properties, Some(5));
1285    }
1286
1287    #[test]
1288    fn string_constraints_compile_into_option_string_fields() {
1289        let compiled = compile_schema(
1290            "AWS::Test::T",
1291            &json!({
1292                "description": "resource documentation",
1293                "properties": {
1294                    "P": { "type": "string", "pattern": "^x$", "format": "uri", "description": "desc" }
1295                },
1296                "replacementStrategy": "delete_then_create",
1297                "documentationUrl": "https://docs",
1298                "sourceUrl": "https://src"
1299            }),
1300        );
1301        let p = &compiled.properties["P"];
1302        assert_eq!(p.pattern.as_deref(), Some("^x$"));
1303        assert_eq!(p.format.as_deref(), Some("uri"));
1304        assert_eq!(compiled.replacement_strategy.as_deref(), Some("delete_then_create"));
1305        assert_eq!(compiled.documentation_url.as_deref(), Some("https://docs"));
1306        assert_eq!(compiled.source_url.as_deref(), Some("https://src"));
1307        // `description` is an annotation: it is neither compiled nor serialized,
1308        // so the bundled artifact does not carry documentation prose.
1309        let serialized = serde_json::to_string(&compiled).expect("compiled schema serializes");
1310        assert!(!serialized.contains("description"), "description must not reach the compiled schema: {serialized}");
1311    }
1312
1313    #[test]
1314    fn metadata_pointer_arrays_all_strip_properties_prefix() {
1315        // Confirm every keyword in METADATA_POINTER_ARRAYS is handled by
1316        // compile_schema and produces stripped dot-notation paths.
1317        let compiled = compile_schema(
1318            "AWS::Test::T",
1319            &json!({
1320                "readOnlyProperties": ["/properties/A"],
1321                "writeOnlyProperties": ["/properties/B"],
1322                "createOnlyProperties": ["/properties/C"],
1323                "deprecatedProperties": ["/properties/D"],
1324                "conditionalCreateOnlyProperties": ["/properties/E"],
1325                "primaryIdentifier": ["/properties/F"]
1326            }),
1327        );
1328        assert_eq!(compiled.read_only_properties, vec!["A"]);
1329        assert_eq!(compiled.write_only_properties, vec!["B"]);
1330        assert_eq!(compiled.create_only_properties, vec!["C"]);
1331        assert_eq!(compiled.deprecated_properties, vec!["D"]);
1332        assert_eq!(compiled.conditional_create_only_properties, vec!["E"]);
1333        assert_eq!(compiled.primary_identifier, vec!["F"]);
1334    }
1335
1336    #[test]
1337    fn definitions_ref_prefix_is_used_in_compile_prop() {
1338        let compiled = compile_schema(
1339            "AWS::Test::T",
1340            &json!({
1341                "properties": { "P": { "$ref": "#/definitions/D" } },
1342                "definitions": { "D": { "type": "object" } }
1343            }),
1344        );
1345        assert_eq!(compiled.properties["P"].ref_name.as_deref(), Some("D"));
1346    }
1347
1348    #[test]
1349    fn schema_maps_slice_matches_recursive_compilation() {
1350        // All three SCHEMA_MAPS keywords produce keyed maps when present.
1351        let compiled = compile_schema(
1352            "AWS::Test::T",
1353            &json!({
1354                "properties": { "P": { "type": "string" } },
1355                "definitions": { "D": { "type": "object" } }
1356            }),
1357        );
1358        assert!(compiled.properties.contains_key("P"));
1359        assert!(compiled.definitions.contains_key("D"));
1360        // patternProperties are compiled at the property level, not root.
1361        let compiled_prop = compile_schema(
1362            "AWS::Test::T",
1363            &json!({
1364                "properties": {
1365                    "Map": { "type": "object", "patternProperties": { "^k$": { "type": "string" } } }
1366                }
1367            }),
1368        );
1369        assert!(compiled_prop.properties["Map"].pattern_properties.contains_key("^k$"));
1370    }
1371
1372    #[test]
1373    fn ref_siblings_enforce_keeps_constraints_beside_a_ref() {
1374        let compiled = compile_schema_with(
1375            "AWS::Test::T",
1376            &json!({
1377                "properties": { "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" } },
1378                "definitions": { "D": { "type": "string" } }
1379            }),
1380            RefSiblings::Enforce,
1381        );
1382        let prop = &compiled.properties["P"];
1383        assert_eq!(prop.ref_name.as_deref(), Some("D"));
1384        assert_eq!(prop.max_length, Some(3), "Enforce must keep constraint siblings");
1385        assert_eq!(prop.pattern.as_deref(), Some("^a"));
1386    }
1387
1388    #[test]
1389    fn ref_siblings_ignore_drops_everything_beside_a_ref() {
1390        // Draft-07 - the dialect the CloudFormation registry validates with -
1391        // ignores keywords beside a `$ref`. The build pipeline compiles bundled
1392        // schemas this way so the engine never enforces more than
1393        // CloudFormation's own contract.
1394        let compiled = compile_schema_with(
1395            "AWS::Test::T",
1396            &json!({
1397                "properties": {
1398                    "P": { "$ref": "#/definitions/D", "maxLength": 3, "pattern": "^a" },
1399                    "Q": { "$ref": "not-a-definitions-ref", "maxLength": 3 }
1400                },
1401                "definitions": { "D": { "type": "string" } }
1402            }),
1403            RefSiblings::Ignore,
1404        );
1405        let prop = &compiled.properties["P"];
1406        assert_eq!(prop.ref_name.as_deref(), Some("D"));
1407        assert_eq!(prop.max_length, None, "Ignore must drop constraint siblings");
1408        assert_eq!(prop.pattern, None);
1409        let unresolvable = &compiled.properties["Q"];
1410        assert_eq!(unresolvable.ref_name, None);
1411        assert_eq!(
1412            unresolvable.max_length, None,
1413            "a $ref outside definitions compiles to an unconstrained property under Ignore, matching the \
1414             historical bundled artifact"
1415        );
1416    }
1417
1418    #[test]
1419    fn condition_schema_type_is_compiled() {
1420        let cond = compile_condition_schema(&json!({ "type": "object", "required": ["A"] }), RefSiblings::Enforce);
1421        match cond.prop_type.as_ref().expect("the condition's type must be compiled") {
1422            PropType::Single(name) => assert_eq!(name, "object"),
1423            PropType::Multi(names) => panic!("expected a single type, got {names:?}"),
1424        }
1425    }
1426}