Skip to main content

validation_engine/
aws_cli.rs

1use diagnostics::{DetailLevel, Summary, ValidationReport, output};
2use rules::Severity;
3use schema_validator::{PropertyValueType, ResourceSchemaMetadata, SchemaValidator};
4use serde::{Deserialize, Serialize};
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::sync::LazyLock;
7use template_model::compile_pattern;
8
9use crate::{ValidateConfig, ValidationEngine, ValidationError, validate_bytes_with_path};
10
11/// A recursively typed value from an AWS CLI command.
12///
13/// Unlike JSON, this model preserves byte strings such as CloudFormation's
14/// `TemplateBody`. `Unsupported` lets language bindings carry an explicit marker
15/// for a runtime value they cannot represent rather than coercing it silently.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
18#[serde(tag = "type", rename_all = "SCREAMING_SNAKE_CASE")]
19pub enum AwsCliValue {
20    Null,
21    Boolean { value: bool },
22    Integer { value: i64 },
23    UnsignedInteger { value: u64 },
24    Number { value: f64 },
25    String { value: String },
26    Bytes { value: Vec<u8> },
27    Array { items: Vec<AwsCliValue> },
28    Object { entries: HashMap<String, AwsCliValue> },
29    Unsupported { type_name: String },
30}
31
32impl AwsCliValue {
33    /// Converts a JSON value without losing integer width.
34    pub fn from_json(value: serde_json::Value) -> Self {
35        match value {
36            serde_json::Value::Null => Self::Null,
37            serde_json::Value::Bool(value) => Self::Boolean { value },
38            serde_json::Value::Number(value) => {
39                if let Some(value) = value.as_i64() {
40                    Self::Integer { value }
41                } else if let Some(value) = value.as_u64() {
42                    Self::UnsignedInteger { value }
43                } else if let Some(value) = value.as_f64() {
44                    Self::Number { value }
45                } else {
46                    Self::Unsupported { type_name: "JSON number".into() }
47                }
48            }
49            serde_json::Value::String(value) => Self::String { value },
50            serde_json::Value::Array(items) => Self::Array { items: items.into_iter().map(Self::from_json).collect() },
51            serde_json::Value::Object(entries) => Self::Object {
52                entries: entries.into_iter().map(|(key, value)| (key, Self::from_json(value))).collect(),
53            },
54        }
55    }
56
57    /// Converts to JSON when this value and all of its children are JSON-safe.
58    pub fn to_json(&self) -> Result<serde_json::Value, String> {
59        self.json_value().ok_or_else(|| match self {
60            Self::Bytes { .. } => "byte strings are not JSON values".to_string(),
61            Self::Number { .. } => "non-finite numbers are not JSON values".to_string(),
62            Self::Unsupported { type_name } => format!("{type_name} is not a supported request value"),
63            _ => "a nested request value is not JSON-compatible".to_string(),
64        })
65    }
66
67    fn json_value(&self) -> Option<serde_json::Value> {
68        match self {
69            Self::Null => Some(serde_json::Value::Null),
70            Self::Boolean { value } => Some(serde_json::Value::Bool(*value)),
71            Self::Integer { value } => Some(serde_json::json!(value)),
72            Self::UnsignedInteger { value } => Some(serde_json::json!(value)),
73            Self::Number { value } => serde_json::Number::from_f64(*value).map(serde_json::Value::Number),
74            Self::String { value } => Some(serde_json::Value::String(value.clone())),
75            Self::Bytes { .. } | Self::Unsupported { .. } => None,
76            Self::Array { items } => {
77                items.iter().map(Self::json_value).collect::<Option<Vec<_>>>().map(serde_json::Value::Array)
78            }
79            Self::Object { entries } => entries
80                .iter()
81                .map(|(key, value)| value.json_value().map(|value| (key.clone(), value)))
82                .collect::<Option<serde_json::Map<_, _>>>()
83                .map(serde_json::Value::Object),
84        }
85    }
86}
87
88impl From<serde_json::Value> for AwsCliValue {
89    fn from(value: serde_json::Value) -> Self {
90        Self::from_json(value)
91    }
92}
93
94/// AWS service, operation, and input values needed to model one AWS CLI command
95/// as CloudFormation resource state.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
98#[serde(rename_all = "camelCase")]
99pub struct AwsCliCommandContext {
100    pub service_name: String,
101    pub operation_name: String,
102    pub parameters: HashMap<String, AwsCliValue>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
105    pub service_prefix: Option<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
108    pub http_method: Option<String>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
111    pub is_read_only: Option<bool>,
112}
113
114/// Idiomatic Rust name for the AWS CLI command record. The `Context`-suffixed
115/// struct is the name UniFFI exports to the language bindings, which keeps the
116/// generated native record distinct from each binding's friendly `AwsCliCommand`
117/// wrapper so wildcard imports never collide.
118pub type AwsCliCommand = AwsCliCommandContext;
119
120impl AwsCliCommandContext {
121    pub fn new(
122        service_name: impl Into<String>,
123        operation_name: impl Into<String>,
124        parameters: impl IntoIterator<Item = (String, AwsCliValue)>,
125    ) -> Self {
126        Self {
127            service_name: service_name.into(),
128            operation_name: operation_name.into(),
129            parameters: parameters.into_iter().collect(),
130            service_prefix: None,
131            http_method: None,
132            is_read_only: None,
133        }
134    }
135
136    pub fn with_service_prefix(mut self, service_prefix: impl Into<String>) -> Self {
137        self.service_prefix = Some(service_prefix.into());
138        self
139    }
140
141    pub fn with_http_method(mut self, http_method: impl Into<String>) -> Self {
142        self.http_method = Some(http_method.into());
143        self
144    }
145
146    pub fn with_read_only(mut self, is_read_only: bool) -> Self {
147        self.is_read_only = Some(is_read_only);
148        self
149    }
150
151    fn default_file_path(&self) -> String {
152        format!("aws-cli://{}/{}", self.service_name, self.operation_name)
153    }
154}
155
156/// Closed classification of an AWS API operation.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
159#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
160pub enum AwsCliOperationKind {
161    ReadOnly,
162    CloudFormationCreate,
163    CloudFormationUpdate,
164    CloudFormationDelete,
165    DataPlaneMutation,
166    UnmappedMutation,
167}
168
169/// Whether a request reached template validation or was conservatively skipped.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
171#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
172#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
173pub enum AwsCliCommandValidationStatus {
174    Validated,
175    Skipped,
176}
177
178/// Provenance of the template validated for an AWS CLI command.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
181#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
182pub enum AwsCliTemplateSource {
183    TemplateBody,
184    CloudControlDesiredState,
185    SynthesizedCreate,
186    SynthesizedUpdate,
187}
188
189/// Canonical result for AWS CLI command validation.
190///
191/// The report is projected at the `STANDARD` detail level and gated at the
192/// `WARN` severity floor: a command is a deployment action, so Info-level
193/// guidance has nothing for the caller to act on.
194#[derive(Debug, Clone, Serialize)]
195#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
196#[serde(rename_all = "camelCase")]
197#[must_use]
198pub struct AwsCliCommandValidation {
199    pub operation_kind: AwsCliOperationKind,
200    pub status: AwsCliCommandValidationStatus,
201    pub template_source: Option<AwsCliTemplateSource>,
202    pub resource_types: Vec<String>,
203    pub reason: String,
204    pub report: Option<output::ValidationReport>,
205    /// The exact template bytes that were validated, or `None` when the request
206    /// was skipped. For `TemplateBody` requests, this is the caller's original
207    /// bytes without reserializing. For synthesized requests, this is the
208    /// generated JSON template. Consumers can display this to show the modeled
209    /// template that produced the diagnostics.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
212    pub template: Option<Vec<u8>>,
213}
214
215/// Classifies, models, and validates one AWS CLI command entirely offline.
216///
217/// The template-validation configuration is fixed — `STANDARD` detail, `WARN`
218/// severity gate, default filters — and cannot be tuned by callers.
219pub fn validate_aws_cli_command(
220    engine: &dyn ValidationEngine,
221    schema_validator: &SchemaValidator,
222    request: &AwsCliCommand,
223) -> Result<AwsCliCommandValidation, ValidationError> {
224    validate_aws_cli_command_with_path(engine, schema_validator, request, request.default_file_path())
225}
226
227/// Same as [`validate_aws_cli_command`], with an explicit report path supplied
228/// by the embedding application.
229pub fn validate_aws_cli_command_with_path(
230    engine: &dyn ValidationEngine,
231    schema_validator: &SchemaValidator,
232    request: &AwsCliCommand,
233    file_path: String,
234) -> Result<AwsCliCommandValidation, ValidationError> {
235    let classification = classify_operation(request, schema_validator)?;
236    let synthesis = synthesize_request(request, &classification, schema_validator)?;
237    let Some(template) = synthesis.template else {
238        return Ok(AwsCliCommandValidation {
239            operation_kind: classification.kind,
240            status: AwsCliCommandValidationStatus::Skipped,
241            template_source: None,
242            resource_types: synthesis.resource_types,
243            reason: synthesis.reason,
244            report: None,
245            template: None,
246        });
247    };
248
249    let mut report =
250        validate_bytes_with_path(engine, schema_validator, &template, aws_cli_validate_config(), file_path)?;
251    if synthesis.source != Some(AwsCliTemplateSource::TemplateBody) {
252        suppress_template_authoring_advice(&mut report);
253    }
254    if let Some(properties) = synthesis.diagnostic_properties.as_ref() {
255        scope_synthesized_report(&mut report, properties);
256    }
257    Ok(AwsCliCommandValidation {
258        operation_kind: classification.kind,
259        status: AwsCliCommandValidationStatus::Validated,
260        template_source: synthesis.source,
261        resource_types: synthesis.resource_types,
262        reason: synthesis.reason,
263        report: Some(report.to_report(DetailLevel::Standard)),
264        template: Some(template),
265    })
266}
267
268/// The fixed template-validation configuration every AWS CLI command runs
269/// with: the defaults, at `STANDARD` detail because a modeled command has no
270/// user-authored source to annotate, gated at `Warn` because a command is a
271/// deployment action, not template authoring, so Info-level guidance (best
272/// practices, replacement-on-update notes) has nothing for the caller to act
273/// on. It is not caller-configurable, so every embedding reports the same
274/// findings for the same command.
275fn aws_cli_validate_config() -> ValidateConfig {
276    ValidateConfig { detail_level: DetailLevel::Standard, severity_level: Severity::Warn, ..ValidateConfig::default() }
277}
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
279#[serde(rename_all = "lowercase")]
280enum AdapterPhase {
281    Create,
282    Update,
283    Delete,
284}
285
286#[derive(Debug, Clone, PartialEq, Deserialize)]
287struct PropertyMapping {
288    source: String,
289    target: String,
290    /// Input values the CloudFormation schema rejects without the API model
291    /// proving that the service rejects them too.
292    #[serde(default)]
293    unrepresentable: Option<UnrepresentableDomain>,
294}
295
296/// The part of an API input's value domain that the mapped CloudFormation
297/// property rejects but the service is not known to reject: either the botocore
298/// model declares a wider domain (an extra enum member, a looser bound or
299/// pattern) or it declares no corresponding constraint at all. A request value
300/// inside this domain may be legal for the service, so reporting the
301/// CloudFormation constraint against it could be a false finding; synthesis is
302/// skipped instead. Each bound is the CloudFormation constraint the API does not
303/// enforce.
304#[derive(Debug, Clone, PartialEq, Default, Deserialize)]
305#[serde(default)]
306struct UnrepresentableDomain {
307    /// API enum members the CloudFormation schema rejects.
308    rejected_values: Vec<serde_json::Value>,
309    /// The CloudFormation enum when the API declares none: any other value is
310    /// unrepresentable.
311    allowed_values: Vec<serde_json::Value>,
312    minimum: Option<f64>,
313    maximum: Option<f64>,
314    min_length: Option<u64>,
315    max_length: Option<u64>,
316    pattern: Option<PatternDivergence>,
317    items: Option<Box<UnrepresentableDomain>>,
318    tag_key: Option<Box<UnrepresentableDomain>>,
319    tag_value: Option<Box<UnrepresentableDomain>>,
320}
321
322/// A CloudFormation `pattern` the API does not enforce. When the API declares
323/// its own `pattern` that differs textually, whether the two admit different
324/// values cannot be decided from the regexes alone, so the divergence is
325/// settled per request value: a value the API pattern accepts and the
326/// CloudFormation pattern rejects has no CloudFormation representation. When
327/// the API declares no `pattern`, every value the CloudFormation pattern
328/// rejects is unrepresentable.
329#[derive(Debug, Clone, PartialEq, Deserialize)]
330struct PatternDivergence {
331    /// The botocore pattern anchored to the whole value, as the service applies
332    /// it; absent when the API declares no pattern for the input.
333    #[serde(default)]
334    api: Option<String>,
335    /// The compiled-schema pattern, applied as the schema validator applies it.
336    cloudformation: String,
337}
338
339impl PatternDivergence {
340    /// Whether `value` is outside the CloudFormation pattern while the API
341    /// pattern, if any, accepts it. A CloudFormation pattern this tool cannot
342    /// compile rejects nothing, because the schema validator compiles patterns
343    /// the same way and reports no finding for one it cannot compile. Errs when
344    /// the API pattern is not a regex this tool can evaluate, because a
345    /// divergence that cannot be settled must not be silently ignored.
346    fn rejects(&self, value: &str) -> Result<bool, String> {
347        let Some(cloudformation) = compile_pattern(&self.cloudformation) else {
348            return Ok(false);
349        };
350        let api = self
351            .api
352            .as_deref()
353            .map(|api_pattern| {
354                compile_pattern(api_pattern).ok_or_else(|| format!("API pattern '{api_pattern}' cannot be compiled"))
355            })
356            .transpose()?;
357        let api_accepts = api.is_none_or(|api| api.is_match(value));
358        Ok(api_accepts && !cloudformation.is_match(value))
359    }
360}
361
362/// Suffix shared by every rejection message so a skip reason states why the
363/// constraint cannot be reported against the command: the API either declares a
364/// wider domain or none at all.
365const API_DOES_NOT_ENFORCE: &str = "a constraint the API does not enforce";
366
367impl UnrepresentableDomain {
368    /// Describes why `value` has no CloudFormation representation, or `None`
369    /// when every part of it is representable. Errs only when the domain itself
370    /// cannot be evaluated, which is a defect in the generated catalog.
371    fn rejection(&self, value: &AwsCliValue) -> Result<Option<String>, String> {
372        if let Some(json_value) = value.json_value() {
373            if self.rejected_values.contains(&json_value) {
374                return Ok(Some(format!(
375                    "value {json_value} is outside the CloudFormation enum, {API_DOES_NOT_ENFORCE}"
376                )));
377            }
378            if !self.allowed_values.is_empty() && !self.allowed_values.contains(&json_value) {
379                return Ok(Some(format!(
380                    "value {json_value} is not one of the CloudFormation values {}, {API_DOES_NOT_ENFORCE}",
381                    serde_json::Value::Array(self.allowed_values.clone())
382                )));
383            }
384        }
385        match value {
386            AwsCliValue::String { value } => {
387                if let Some(rejection) = self.length_rejection(value.chars().count() as u64, "length") {
388                    return Ok(Some(rejection));
389                }
390                self.pattern_rejection(value)
391            }
392            AwsCliValue::Integer { value } => Ok(self.numeric_rejection(*value as f64)),
393            AwsCliValue::UnsignedInteger { value } => Ok(self.numeric_rejection(*value as f64)),
394            AwsCliValue::Number { value } => Ok(self.numeric_rejection(*value)),
395            AwsCliValue::Array { items } => {
396                if let Some(rejection) = self.length_rejection(items.len() as u64, "item count") {
397                    return Ok(Some(rejection));
398                }
399                let Some(domain) = self.items.as_ref() else {
400                    return Ok(None);
401                };
402                for item in items {
403                    if let Some(rejection) = domain.rejection(item)? {
404                        return Ok(Some(rejection));
405                    }
406                }
407                Ok(None)
408            }
409            AwsCliValue::Object { entries } => {
410                if let Some(rejection) = self.length_rejection(entries.len() as u64, "entry count") {
411                    return Ok(Some(rejection));
412                }
413                for (key, entry) in entries {
414                    if let Some(domain) = self.tag_key.as_ref()
415                        && let Some(rejection) = domain.rejection(&AwsCliValue::String { value: key.clone() })?
416                    {
417                        return Ok(Some(rejection));
418                    }
419                    if let Some(domain) = self.tag_value.as_ref()
420                        && let Some(rejection) = domain.rejection(entry)?
421                    {
422                        return Ok(Some(rejection));
423                    }
424                }
425                Ok(None)
426            }
427            AwsCliValue::Null
428            | AwsCliValue::Boolean { .. }
429            | AwsCliValue::Bytes { .. }
430            | AwsCliValue::Unsupported { .. } => Ok(None),
431        }
432    }
433
434    fn pattern_rejection(&self, value: &str) -> Result<Option<String>, String> {
435        let Some(divergence) = self.pattern.as_ref() else {
436            return Ok(None);
437        };
438        if divergence.rejects(value)? {
439            return Ok(Some(format!(
440                "value {value:?} does not match the CloudFormation pattern '{}', {API_DOES_NOT_ENFORCE}",
441                divergence.cloudformation
442            )));
443        }
444        Ok(None)
445    }
446
447    fn numeric_rejection(&self, value: f64) -> Option<String> {
448        if let Some(minimum) = self.minimum
449            && value < minimum
450        {
451            return Some(format!(
452                "value {value} is below the CloudFormation minimum {minimum}, {API_DOES_NOT_ENFORCE}"
453            ));
454        }
455        if let Some(maximum) = self.maximum
456            && value > maximum
457        {
458            return Some(format!(
459                "value {value} is above the CloudFormation maximum {maximum}, {API_DOES_NOT_ENFORCE}"
460            ));
461        }
462        None
463    }
464
465    fn length_rejection(&self, length: u64, measure: &str) -> Option<String> {
466        if let Some(min_length) = self.min_length
467            && length < min_length
468        {
469            return Some(format!(
470                "{measure} {length} is below the CloudFormation minimum {min_length}, {API_DOES_NOT_ENFORCE}"
471            ));
472        }
473        if let Some(max_length) = self.max_length
474            && length > max_length
475        {
476            return Some(format!(
477                "{measure} {length} is above the CloudFormation maximum {max_length}, {API_DOES_NOT_ENFORCE}"
478            ));
479        }
480        None
481    }
482}
483
484#[derive(Debug, Clone, PartialEq, Deserialize)]
485struct OperationAdapter {
486    service: String,
487    operation: String,
488    phase: AdapterPhase,
489    cfn_type: String,
490    mappings: Vec<PropertyMapping>,
491    /// Request-control fields safe to ignore during all-or-nothing synthesis.
492    #[serde(default)]
493    ignored_inputs: Vec<String>,
494}
495
496#[derive(Debug, Deserialize)]
497struct OperationCatalog {
498    format_version: u32,
499    adapters: Vec<OperationAdapter>,
500}
501
502// These entries exposed dependent or ambiguous provider permissions in an
503// older generated artifact. Filtering them here keeps shipped catalogs
504// conservative while the maintenance pipeline catches up.
505/// Generated by `data-source/scripts/generate_aws_cli_catalog.py`: each entry is
506/// derived from the resource type's own provider handler metadata, resolved
507/// against botocore service models, and structurally verified against the
508/// compiled CloudFormation schemas. Each mapping also records the value domain
509/// the schema rejects without the API enforcing the same constraint (enum
510/// members, bounds, lengths, sizes, and a regex pattern), so a value the
511/// service may accept but CloudFormation rejects skips synthesis instead of
512/// producing a finding.
513/// Only exact service+operation keys resolve; unregistered operations stay
514/// unmapped.
515static ADAPTER_REGISTRY: LazyLock<Result<HashMap<(String, String), OperationAdapter>, String>> =
516    LazyLock::new(|| parse_adapter_registry(&data_source::embedded::AWS_CLI_OPERATION_CATALOG_BYTES));
517
518fn parse_adapter_registry(bytes: &[u8]) -> Result<HashMap<(String, String), OperationAdapter>, String> {
519    let catalog: OperationCatalog = serde_json::from_slice(bytes)
520        .map_err(|error| format!("embedded AWS CLI operation catalog is invalid: {error}"))?;
521    if catalog.format_version != 1 {
522        return Err(format!("unsupported AWS CLI operation catalog format {}", catalog.format_version));
523    }
524    let mut registry = HashMap::new();
525    for adapter in catalog.adapters {
526        if adapter.service.trim().is_empty()
527            || adapter.operation.trim().is_empty()
528            || adapter.cfn_type.trim().is_empty()
529        {
530            return Err("AWS CLI operation catalog identities must not be blank".into());
531        }
532        let key = (normalize_service(&adapter.service), adapter.operation.clone());
533        if let Some(previous) = registry.insert(key, adapter) {
534            return Err(format!("duplicate AWS CLI operation catalog key {}:{}", previous.service, previous.operation));
535        }
536    }
537    Ok(registry)
538}
539
540fn adapter_registry() -> Result<&'static HashMap<(String, String), OperationAdapter>, ValidationError> {
541    ADAPTER_REGISTRY.as_ref().map_err(|message| ValidationError::Engine(message.clone()))
542}
543
544/// CloudFormation operations that accept TemplateBody per botocore service
545/// definitions. Only these exact service+operation pairs treat a TemplateBody
546/// parameter as a CloudFormation template.
547const TEMPLATE_BODY_OPERATIONS: &[(&str, &str)] = &[
548    ("cloudformation", "CreateChangeSet"),
549    ("cloudformation", "CreateStack"),
550    ("cloudformation", "CreateStackSet"),
551    ("cloudformation", "EstimateTemplateCost"),
552    ("cloudformation", "GetTemplateSummary"),
553    ("cloudformation", "UpdateStack"),
554    ("cloudformation", "UpdateStackSet"),
555    ("cloudformation", "ValidateTemplate"),
556];
557
558/// Callers pass canonical botocore service names, so case-normalization is the only lookup adjustment.
559fn normalize_service(name: &str) -> String {
560    name.to_ascii_lowercase()
561}
562
563fn lookup_adapter(service: &str, operation: &str) -> Result<Option<&'static OperationAdapter>, ValidationError> {
564    let key = (normalize_service(service), operation.to_string());
565    Ok(adapter_registry()?.get(&key))
566}
567
568fn is_template_body_operation(service: &str, operation: &str) -> bool {
569    let normalized = normalize_service(service);
570    TEMPLATE_BODY_OPERATIONS.iter().any(|(s, o)| normalize_service(s) == normalized && *o == operation)
571}
572
573fn template_body_operation_kind(service: &str, operation: &str) -> Option<AwsCliOperationKind> {
574    if normalize_service(service) != "cloudformation" {
575        return None;
576    }
577    match operation {
578        "CreateChangeSet" | "CreateStack" | "CreateStackSet" => Some(AwsCliOperationKind::CloudFormationCreate),
579        "UpdateStack" | "UpdateStackSet" => Some(AwsCliOperationKind::CloudFormationUpdate),
580        "EstimateTemplateCost" | "GetTemplateSummary" | "ValidateTemplate" => Some(AwsCliOperationKind::ReadOnly),
581        _ => None,
582    }
583}
584
585fn is_cloud_control_service(service: &str) -> bool {
586    normalize_service(service) == "cloudcontrol"
587}
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589enum OperationPhase {
590    Read,
591    Create,
592    Update,
593    Delete,
594    Data,
595    Unknown,
596}
597
598#[derive(Debug, Clone)]
599struct Classification {
600    kind: AwsCliOperationKind,
601    candidates: Vec<String>,
602}
603
604const READ_VERBS: &[&str] = &[
605    "Calculate",
606    "Check",
607    "Compare",
608    "Contains",
609    "Count",
610    "Decode",
611    "Describe",
612    "Discover",
613    "Estimate",
614    "Filter",
615    "Find",
616    "Forecast",
617    "Get",
618    "Head",
619    "Is",
620    "List",
621    "Lookup",
622    "Preview",
623    "Query",
624    "Read",
625    "Resolve",
626    "Retrieve",
627    "Sample",
628    "Scan",
629    "Search",
630    "Select",
631    "Simulate",
632    "Validate",
633    "Verify",
634    "View",
635];
636const DATA_PLANE_VERBS: &[&str] = &[
637    "Analyze",
638    "Chat",
639    "Complete",
640    "Convert",
641    "Converse",
642    "Decrypt",
643    "Deliver",
644    "Encrypt",
645    "Execute",
646    "Generate",
647    "Infer",
648    "Invoke",
649    "Meter",
650    "Notify",
651    "Post",
652    "Predict",
653    "Publish",
654    "Receive",
655    "Recognize",
656    "Render",
657    "Respond",
658    "Send",
659    "Signal",
660    "Sign",
661    "Stream",
662    "Synthesize",
663    "Test",
664    "Translate",
665    "Upload",
666    "Write",
667];
668
669const MODIFIER_PREFIXES: &[&str] = &["Admin", "Batch", "Bulk", "Transact"];
670
671fn classify_operation(
672    request: &AwsCliCommand,
673    schema_validator: &SchemaValidator,
674) -> Result<Classification, ValidationError> {
675    if let Some(kind) = template_body_operation_kind(&request.service_name, &request.operation_name) {
676        return Ok(Classification { kind, candidates: Vec::new() });
677    }
678
679    // The modeled read-only trait is authoritative even over a registered adapter.
680    if request.is_read_only == Some(true) {
681        return Ok(Classification { kind: AwsCliOperationKind::ReadOnly, candidates: Vec::new() });
682    }
683
684    if let Some(adapter) = lookup_adapter(&request.service_name, &request.operation_name)? {
685        let kind = match adapter.phase {
686            AdapterPhase::Create => AwsCliOperationKind::CloudFormationCreate,
687            AdapterPhase::Update => AwsCliOperationKind::CloudFormationUpdate,
688            AdapterPhase::Delete => AwsCliOperationKind::CloudFormationDelete,
689        };
690        return Ok(Classification { kind, candidates: vec![adapter.cfn_type.clone()] });
691    }
692
693    let words = operation_words(&request.operation_name);
694    let verb = effective_verb(&words);
695    let phase = operation_phase(request, verb);
696
697    if phase == OperationPhase::Read {
698        return Ok(Classification { kind: AwsCliOperationKind::ReadOnly, candidates: Vec::new() });
699    }
700    if phase == OperationPhase::Data {
701        return Ok(Classification { kind: AwsCliOperationKind::DataPlaneMutation, candidates: Vec::new() });
702    }
703
704    if is_cloud_control_service(&request.service_name) {
705        let is_resource_op =
706            matches!(request.operation_name.as_str(), "CreateResource" | "UpdateResource" | "DeleteResource");
707        if is_resource_op && let Some(type_name) = explicit_valid_type_name(request, schema_validator) {
708            let kind = match request.operation_name.as_str() {
709                "CreateResource" => AwsCliOperationKind::CloudFormationCreate,
710                "DeleteResource" => AwsCliOperationKind::CloudFormationDelete,
711                _ => AwsCliOperationKind::UnmappedMutation,
712            };
713            return Ok(Classification { kind, candidates: vec![type_name] });
714        }
715    }
716
717    // Unknown mutation: classify by verb family but never assign resource types.
718    let kind = if DATA_PLANE_IF_UNMAPPED_VERBS.contains(&verb) {
719        AwsCliOperationKind::DataPlaneMutation
720    } else {
721        AwsCliOperationKind::UnmappedMutation
722    };
723    Ok(Classification { kind, candidates: Vec::new() })
724}
725
726const DATA_PLANE_IF_UNMAPPED_VERBS: &[&str] =
727    &["Execute", "Invoke", "Post", "Publish", "Put", "Send", "Upload", "Write"];
728
729fn operation_phase(request: &AwsCliCommand, verb: &str) -> OperationPhase {
730    if request.is_read_only == Some(true) || READ_VERBS.contains(&verb) {
731        return OperationPhase::Read;
732    }
733    if DATA_PLANE_VERBS.contains(&verb) {
734        return OperationPhase::Data;
735    }
736    if CREATE_VERBS.contains(&verb) {
737        return OperationPhase::Create;
738    }
739    if UPDATE_VERBS.contains(&verb) {
740        return OperationPhase::Update;
741    }
742    if DELETE_VERBS.contains(&verb) {
743        return OperationPhase::Delete;
744    }
745    match request.http_method.as_deref().map(str::to_ascii_uppercase).as_deref() {
746        Some("GET" | "HEAD") => return OperationPhase::Read,
747        Some("DELETE") => return OperationPhase::Delete,
748        _ => {}
749    }
750    OperationPhase::Unknown
751}
752
753const CREATE_VERBS: &[&str] = &[
754    "Add",
755    "Allocate",
756    "Build",
757    "Clone",
758    "Copy",
759    "Create",
760    "Define",
761    "Deploy",
762    "Import",
763    "Index",
764    "Initialize",
765    "Install",
766    "Instantiate",
767    "Invite",
768    "Issue",
769    "Join",
770    "Launch",
771    "Provision",
772    "Purchase",
773    "Register",
774    "Request",
775    "Restore",
776    "Run",
777    "Schedule",
778    "Start",
779    "Submit",
780];
781const UPDATE_VERBS: &[&str] = &[
782    "Accept",
783    "Activate",
784    "Apply",
785    "Approve",
786    "Assign",
787    "Associate",
788    "Attach",
789    "Authorize",
790    "Change",
791    "Configure",
792    "Connect",
793    "Deactivate",
794    "Decrease",
795    "Disable",
796    "Disassociate",
797    "Dissociate",
798    "Detach",
799    "Enable",
800    "Grant",
801    "Increase",
802    "Link",
803    "Lock",
804    "Merge",
805    "Modify",
806    "Move",
807    "Promote",
808    "Put",
809    "Reboot",
810    "Refresh",
811    "Replace",
812    "Reset",
813    "Resize",
814    "Restart",
815    "Resume",
816    "Rotate",
817    "Set",
818    "Share",
819    "Subscribe",
820    "Suspend",
821    "Swap",
822    "Tag",
823    "Transfer",
824    "Unassign",
825    "Unlock",
826    "Unshare",
827    "Unsubscribe",
828    "Untag",
829    "Update",
830    "Upgrade",
831];
832const DELETE_VERBS: &[&str] = &[
833    "Abort",
834    "Block",
835    "Cancel",
836    "Close",
837    "Decline",
838    "Delete",
839    "Deny",
840    "Deprovision",
841    "Deregister",
842    "Destroy",
843    "Discard",
844    "Dispose",
845    "Expire",
846    "Forget",
847    "Leave",
848    "Purge",
849    "Reject",
850    "Release",
851    "Remove",
852    "Retire",
853    "Revoke",
854    "Shutdown",
855    "Stop",
856    "Terminate",
857    "Unregister",
858];
859
860fn operation_words(operation_name: &str) -> Vec<String> {
861    let characters: Vec<char> = operation_name.chars().collect();
862    if characters.is_empty() {
863        return Vec::new();
864    }
865    let mut words = Vec::new();
866    let mut start = 0;
867    for index in 1..characters.len() {
868        let previous = characters[index - 1];
869        let current = characters[index];
870        let next = characters.get(index + 1).copied();
871        let boundary = (current.is_ascii_digit() && !previous.is_ascii_digit())
872            || (!current.is_ascii_digit() && previous.is_ascii_digit())
873            || (current.is_ascii_uppercase() && previous.is_ascii_lowercase())
874            || (current.is_ascii_uppercase()
875                && previous.is_ascii_uppercase()
876                && next.is_some_and(|next| next.is_ascii_lowercase()));
877        if boundary {
878            words.push(characters[start..index].iter().collect());
879            start = index;
880        }
881    }
882    words.push(characters[start..].iter().collect());
883    words
884}
885
886fn effective_verb(words: &[String]) -> &str {
887    if words.len() > 1 && MODIFIER_PREFIXES.contains(&words[0].as_str()) {
888        &words[1]
889    } else {
890        words.first().map(String::as_str).unwrap_or("")
891    }
892}
893
894fn explicit_valid_type_name(request: &AwsCliCommand, schema_validator: &SchemaValidator) -> Option<String> {
895    match request.parameters.get("TypeName") {
896        Some(AwsCliValue::String { value }) if schema_validator.has_resource_type(value) => Some(value.clone()),
897        _ => None,
898    }
899}
900struct Synthesis {
901    template: Option<Vec<u8>>,
902    source: Option<AwsCliTemplateSource>,
903    reason: String,
904    resource_types: Vec<String>,
905    diagnostic_properties: Option<BTreeSet<String>>,
906}
907
908impl Synthesis {
909    fn skipped(reason: impl Into<String>, resource_types: Vec<String>) -> Self {
910        Self { template: None, source: None, reason: reason.into(), resource_types, diagnostic_properties: None }
911    }
912}
913
914fn synthesize_request(
915    request: &AwsCliCommand,
916    classification: &Classification,
917    schema_validator: &SchemaValidator,
918) -> Result<Synthesis, ValidationError> {
919    let is_cfn_op = is_template_body_operation(&request.service_name, &request.operation_name);
920
921    if is_cfn_op {
922        if let Some(template) = template_body_bytes(request.parameters.get("TemplateBody")) {
923            return Ok(Synthesis {
924                template: Some(template),
925                source: Some(AwsCliTemplateSource::TemplateBody),
926                reason: "using exact request TemplateBody".into(),
927                resource_types: Vec::new(),
928                diagnostic_properties: None,
929            });
930        }
931        if request.parameters.contains_key("TemplateURL") {
932            return Ok(Synthesis::skipped("TemplateURL content is unavailable to the offline validator", Vec::new()));
933        }
934    }
935
936    if classification.kind == AwsCliOperationKind::ReadOnly {
937        return Ok(Synthesis::skipped("read-only calls do not need validation", Vec::new()));
938    }
939
940    let is_cloud_control = is_cloud_control_service(&request.service_name);
941    if is_cloud_control
942        && request.operation_name == "CreateResource"
943        && request.parameters.contains_key("TypeName")
944        && request.parameters.contains_key("DesiredState")
945    {
946        return desired_state_template(request, schema_validator);
947    }
948
949    if is_cloud_control && request.operation_name == "UpdateResource" {
950        let type_names = explicit_valid_type_name(request, schema_validator).map(|t| vec![t]).unwrap_or_default();
951        return Ok(Synthesis::skipped(
952            "Cloud Control UpdateResource uses PatchDocument and cannot be synthesized",
953            type_names,
954        ));
955    }
956
957    adapter_template(request, classification, schema_validator)
958}
959
960fn template_body_bytes(value: Option<&AwsCliValue>) -> Option<Vec<u8>> {
961    match value {
962        Some(AwsCliValue::Bytes { value }) if !value.is_empty() => Some(value.clone()),
963        Some(AwsCliValue::String { value }) if !value.is_empty() => Some(value.as_bytes().to_vec()),
964        _ => None,
965    }
966}
967
968fn desired_state_template(
969    request: &AwsCliCommand,
970    schema_validator: &SchemaValidator,
971) -> Result<Synthesis, ValidationError> {
972    let Some(AwsCliValue::String { value: type_name }) = request.parameters.get("TypeName") else {
973        return Ok(Synthesis::skipped("DesiredState has no known CloudFormation TypeName", Vec::new()));
974    };
975    if !schema_validator.has_resource_type(type_name) {
976        return Ok(Synthesis::skipped("DesiredState has no known CloudFormation TypeName", Vec::new()));
977    }
978    let Some(desired_state) = request.parameters.get("DesiredState") else {
979        return Ok(Synthesis::skipped("DesiredState is missing", vec![type_name.clone()]));
980    };
981    let properties = match desired_state {
982        AwsCliValue::String { value } if !value.is_empty() => serde_json::from_str(value),
983        AwsCliValue::Bytes { value } if !value.is_empty() => serde_json::from_slice(value),
984        _ => return Ok(Synthesis::skipped("DesiredState is missing", vec![type_name.clone()])),
985    };
986    let properties: serde_json::Value = match properties {
987        Ok(properties) => properties,
988        Err(_) => return Ok(Synthesis::skipped("DesiredState is not valid JSON", vec![type_name.clone()])),
989    };
990    let Some(properties) = properties.as_object() else {
991        return Ok(Synthesis::skipped("DesiredState is not a JSON object", vec![type_name.clone()]));
992    };
993    Ok(Synthesis {
994        template: Some(resource_template(type_name, properties)?),
995        source: Some(AwsCliTemplateSource::CloudControlDesiredState),
996        reason: "wrapped exact Cloud Control desired state".into(),
997        resource_types: vec![type_name.clone()],
998        diagnostic_properties: None,
999    })
1000}
1001
1002fn adapter_template(
1003    request: &AwsCliCommand,
1004    classification: &Classification,
1005    schema_validator: &SchemaValidator,
1006) -> Result<Synthesis, ValidationError> {
1007    if !matches!(
1008        classification.kind,
1009        AwsCliOperationKind::CloudFormationCreate | AwsCliOperationKind::CloudFormationUpdate
1010    ) {
1011        return Ok(Synthesis::skipped(
1012            "classification has no representable resource state",
1013            classification.candidates.clone(),
1014        ));
1015    }
1016    if classification.candidates.len() != 1 {
1017        return Ok(Synthesis::skipped(
1018            "no adapter maps this operation to a CloudFormation resource",
1019            classification.candidates.clone(),
1020        ));
1021    }
1022
1023    let type_name = &classification.candidates[0];
1024    let Some(schema) = schema_validator.resource_schema_metadata(type_name) else {
1025        return Ok(Synthesis::skipped("CloudFormation resource type is unknown", vec![type_name.clone()]));
1026    };
1027
1028    let adapter = lookup_adapter(&request.service_name, &request.operation_name)?;
1029    let Some(adapter) = adapter else {
1030        return Ok(Synthesis::skipped(
1031            "no adapter maps this operation to a CloudFormation resource",
1032            vec![type_name.clone()],
1033        ));
1034    };
1035
1036    let properties = match map_adapter_properties(&request.parameters, &schema, adapter)? {
1037        AdapterMappingResult::Mapped(properties) => properties,
1038        AdapterMappingResult::Skip(reason) => {
1039            return Ok(Synthesis::skipped(reason, vec![type_name.clone()]));
1040        }
1041    };
1042
1043    if properties.is_empty() {
1044        return Ok(Synthesis::skipped("no request parameters map to resource properties", vec![type_name.clone()]));
1045    }
1046
1047    let diagnostic_properties = Some(properties.keys().cloned().collect::<BTreeSet<_>>());
1048    let source = if adapter.phase == AdapterPhase::Update {
1049        AwsCliTemplateSource::SynthesizedUpdate
1050    } else {
1051        AwsCliTemplateSource::SynthesizedCreate
1052    };
1053    let reason = if adapter.phase == AdapterPhase::Update {
1054        "synthesized explicitly updated CloudFormation properties"
1055    } else {
1056        "synthesized one unambiguous CloudFormation resource"
1057    };
1058    let template_properties: serde_json::Map<String, serde_json::Value> = properties.into_iter().collect();
1059    Ok(Synthesis {
1060        template: Some(resource_template(type_name, &template_properties)?),
1061        source: Some(source),
1062        reason: reason.into(),
1063        resource_types: vec![type_name.clone()],
1064        diagnostic_properties,
1065    })
1066}
1067
1068#[derive(Debug)]
1069enum AdapterMappingResult {
1070    Mapped(BTreeMap<String, serde_json::Value>),
1071    Skip(String),
1072}
1073
1074fn map_adapter_properties(
1075    parameters: &HashMap<String, AwsCliValue>,
1076    schema: &ResourceSchemaMetadata,
1077    adapter: &OperationAdapter,
1078) -> Result<AdapterMappingResult, ValidationError> {
1079    let mut excluded = schema.read_only_properties.clone();
1080    if adapter.phase == AdapterPhase::Update {
1081        excluded.extend(schema.primary_identifier_properties.iter().cloned());
1082    }
1083
1084    let mut sources = BTreeSet::new();
1085    let mut targets = BTreeSet::new();
1086    let mut mapped_sources: BTreeSet<&str> = BTreeSet::new();
1087    let mut mapped = BTreeMap::new();
1088    for mapping in &adapter.mappings {
1089        if !sources.insert(mapping.source.as_str()) {
1090            return Err(ValidationError::Engine(format!(
1091                "adapter {}:{} has duplicate source parameter '{}'",
1092                adapter.service, adapter.operation, mapping.source
1093            )));
1094        }
1095        if !targets.insert(mapping.target.as_str()) {
1096            return Err(ValidationError::Engine(format!(
1097                "adapter {}:{} has duplicate target property '{}'",
1098                adapter.service, adapter.operation, mapping.target
1099            )));
1100        }
1101        let Some(accepted_types) = schema.property_types.get(&mapping.target) else {
1102            return Err(ValidationError::Engine(format!(
1103                "adapter {}:{} targets property '{}' which does not exist on {}",
1104                adapter.service, adapter.operation, mapping.target, adapter.cfn_type
1105            )));
1106        };
1107        if excluded.contains(&mapping.target) {
1108            return Err(ValidationError::Engine(format!(
1109                "adapter {}:{} targets excluded property '{}' on {}",
1110                adapter.service, adapter.operation, mapping.target, adapter.cfn_type
1111            )));
1112        }
1113        let Some(value) = parameters.get(&mapping.source) else {
1114            continue;
1115        };
1116        mapped_sources.insert(&mapping.source);
1117        if let Some(domain) = mapping.unrepresentable.as_ref() {
1118            let rejection = domain.rejection(value).map_err(|error| {
1119                ValidationError::Engine(format!(
1120                    "adapter {}:{} mapping '{}' -> '{}' has an unevaluable unrepresentable domain: {error}",
1121                    adapter.service, adapter.operation, mapping.source, mapping.target
1122                ))
1123            })?;
1124            if let Some(rejection) = rejection {
1125                return Ok(AdapterMappingResult::Skip(format!(
1126                    "parameter '{}' {rejection}, so it has no representation as property '{}' on {}",
1127                    mapping.source, mapping.target, adapter.cfn_type
1128                )));
1129            }
1130        }
1131        match mapped_value(value, accepted_types, &mapping.target) {
1132            Some(json_value) => {
1133                mapped.insert(mapping.target.clone(), json_value);
1134            }
1135            None => {
1136                return Ok(AdapterMappingResult::Skip(format!(
1137                    "parameter '{}' cannot be represented as property '{}' on {}",
1138                    mapping.source, mapping.target, adapter.cfn_type
1139                )));
1140            }
1141        }
1142    }
1143
1144    // Build the set of parameters that are safe to ignore: explicitly declared
1145    // ignored_inputs, plus primary identifier properties for update adapters.
1146    let mut ignored: BTreeSet<&str> = adapter.ignored_inputs.iter().map(String::as_str).collect();
1147    if adapter.phase == AdapterPhase::Update {
1148        ignored.extend(schema.primary_identifier_properties.iter().map(String::as_str));
1149    }
1150
1151    // All-or-nothing: every supplied parameter must either be mapped or
1152    // in the ignored set. Parameters are visited in name order so the skip
1153    // reason names the same parameter on every run.
1154    for param_name in parameters.keys().collect::<BTreeSet<_>>() {
1155        if mapped_sources.contains(param_name.as_str()) {
1156            continue;
1157        }
1158        if ignored.contains(param_name.as_str()) {
1159            continue;
1160        }
1161        return Ok(AdapterMappingResult::Skip(format!(
1162            "parameter '{}' has no mapping to a property on {}",
1163            param_name, adapter.cfn_type
1164        )));
1165    }
1166    Ok(AdapterMappingResult::Mapped(mapped))
1167}
1168
1169fn resource_template(
1170    type_name: &str,
1171    properties: &serde_json::Map<String, serde_json::Value>,
1172) -> Result<Vec<u8>, ValidationError> {
1173    serde_json::to_vec(&serde_json::json!({
1174        "AWSTemplateFormatVersion": "2010-09-09",
1175        "Resources": {
1176            "Resource": {
1177                "Type": type_name,
1178                "Properties": properties,
1179            }
1180        }
1181    }))
1182    .map_err(|error| ValidationError::Engine(format!("failed to serialize synthesized template: {error}")))
1183}
1184
1185fn mapped_value(
1186    value: &AwsCliValue,
1187    accepted_types: &BTreeSet<PropertyValueType>,
1188    property_name: &str,
1189) -> Option<serde_json::Value> {
1190    // Many AWS APIs use string maps for tags, while CloudFormation uses
1191    // Key/Value object arrays for the same resource state.
1192    if property_name == "Tags"
1193        && accepts_type(accepted_types, PropertyValueType::Array)
1194        && let AwsCliValue::Object { entries } = value
1195        && entries.values().all(|value| matches!(value, AwsCliValue::String { .. }))
1196    {
1197        let mut tags: Vec<(&String, &AwsCliValue)> = entries.iter().collect();
1198        tags.sort_by_key(|(key, _)| key.as_str());
1199        return Some(serde_json::Value::Array(
1200            tags.into_iter()
1201                .filter_map(|(key, value)| match value {
1202                    AwsCliValue::String { value } => Some(serde_json::json!({"Key": key, "Value": value})),
1203                    _ => None,
1204                })
1205                .collect(),
1206        ));
1207    }
1208    if value_matches_types(value, accepted_types) {
1209        return value.json_value();
1210    }
1211    None
1212}
1213
1214fn accepts_type(types: &BTreeSet<PropertyValueType>, expected: PropertyValueType) -> bool {
1215    types.contains(&PropertyValueType::Any) || types.contains(&expected)
1216}
1217
1218fn value_matches_types(value: &AwsCliValue, types: &BTreeSet<PropertyValueType>) -> bool {
1219    let accepts_any = types.contains(&PropertyValueType::Any);
1220    match value {
1221        AwsCliValue::Array { items } => {
1222            (accepts_any || types.contains(&PropertyValueType::Array)) && items.iter().all(is_scalar_api_value)
1223        }
1224        AwsCliValue::Object { .. } => false,
1225        AwsCliValue::Boolean { .. } => accepts_any || types.contains(&PropertyValueType::Boolean),
1226        AwsCliValue::Integer { .. } | AwsCliValue::UnsignedInteger { .. } => {
1227            accepts_any || types.contains(&PropertyValueType::Integer) || types.contains(&PropertyValueType::Number)
1228        }
1229        AwsCliValue::Number { .. } => accepts_any || types.contains(&PropertyValueType::Number),
1230        AwsCliValue::String { .. } => accepts_any || types.contains(&PropertyValueType::String),
1231        AwsCliValue::Null | AwsCliValue::Bytes { .. } | AwsCliValue::Unsupported { .. } => false,
1232    }
1233}
1234
1235fn is_scalar_api_value(value: &AwsCliValue) -> bool {
1236    matches!(
1237        value,
1238        AwsCliValue::Boolean { .. }
1239            | AwsCliValue::Integer { .. }
1240            | AwsCliValue::UnsignedInteger { .. }
1241            | AwsCliValue::Number { .. }
1242            | AwsCliValue::String { .. }
1243    )
1244}
1245/// Rules whose only remediation is a template-authoring construct: replace a
1246/// literal ARN, AMI ID, account ID, availability zone, password, or
1247/// pseudo-parameter lookalike with a parameter, `Ref`/`GetAtt`, mapping, or
1248/// dynamic reference. They judge how a template author expressed a value, not
1249/// whether the value is valid resource state.
1250const TEMPLATE_AUTHORING_ADVICE_RULES: &[&str] = &["W1054", "W2501", "W3010", "W9002", "W9010", "W9013"];
1251
1252/// Resource state modeled from an API request never had a template, so advice
1253/// about how a template author should have expressed a literal cannot be acted
1254/// on and is dropped. Findings about the values themselves are kept.
1255fn suppress_template_authoring_advice(report: &mut ValidationReport) {
1256    retain_diagnostics(report, |diagnostic| !TEMPLATE_AUTHORING_ADVICE_RULES.contains(&diagnostic.rule_id.as_str()));
1257}
1258
1259fn scope_synthesized_report(report: &mut ValidationReport, properties: &BTreeSet<String>) {
1260    retain_diagnostics(report, |diagnostic| {
1261        diagnostic.property_path.as_deref().is_some_and(|path| diagnostic_in_scope(path, properties))
1262    });
1263}
1264
1265fn retain_diagnostics(report: &mut ValidationReport, keep: impl FnMut(&diagnostics::Diagnostic) -> bool) {
1266    let before = report.diagnostics.len();
1267    report.diagnostics.retain(keep);
1268    let removed = before.saturating_sub(report.diagnostics.len()) as u32;
1269    report.metadata.suppressed = report.metadata.suppressed.saturating_add(removed);
1270    report.metadata.counts = summarize_diagnostics(&report.diagnostics);
1271}
1272
1273fn diagnostic_in_scope(property_path: &str, properties: &BTreeSet<String>) -> bool {
1274    properties.iter().any(|property_name| {
1275        [format!("Properties.{property_name}"), format!("/Properties/{property_name}")].into_iter().any(|marker| {
1276            property_path.find(&marker).is_some_and(|start| {
1277                let end = start + marker.len();
1278                end == property_path.len()
1279                    || property_path[end..].chars().next().is_some_and(|separator| matches!(separator, '.' | '[' | '/'))
1280            })
1281        })
1282    })
1283}
1284
1285fn summarize_diagnostics(diagnostics: &[diagnostics::Diagnostic]) -> Summary {
1286    let fatal = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Fatal).count() as u32;
1287    let errors = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Error).count() as u32;
1288    let warnings = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Warn).count() as u32;
1289    let debug = diagnostics.iter().filter(|diagnostic| diagnostic.severity == Severity::Debug).count() as u32;
1290    let informational = diagnostics.len() as u32 - fatal - errors - warnings - debug;
1291    Summary { fatal, errors, warnings, informational, debug }
1292}
1293#[cfg(test)]
1294mod tests {
1295    use super::*;
1296    use diagnostics::{Diagnostic, PhaseMetric, RegisteredDiagnostic};
1297    use rules::{RuleInfo, RuleMetadataEntry};
1298    use std::sync::Arc;
1299    use template_model::SemanticModel;
1300
1301    struct NoopEngine {
1302        metadata: HashMap<String, RuleMetadataEntry>,
1303        init_metric: PhaseMetric,
1304    }
1305
1306    impl Default for NoopEngine {
1307        fn default() -> Self {
1308            Self { metadata: HashMap::new(), init_metric: PhaseMetric { duration_ms: 0.0 } }
1309        }
1310    }
1311
1312    impl ValidationEngine for NoopEngine {
1313        fn engine_name(&self) -> &str {
1314            "noop"
1315        }
1316
1317        fn evaluate_rules(
1318            &self,
1319            _model: &Arc<SemanticModel>,
1320            _config: &ValidateConfig,
1321        ) -> Result<Vec<Diagnostic>, ValidationError> {
1322            Ok(Vec::new())
1323        }
1324
1325        fn list_rules(&self) -> Vec<RuleInfo> {
1326            Vec::new()
1327        }
1328
1329        fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
1330            &self.metadata
1331        }
1332
1333        fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
1334            HashMap::new()
1335        }
1336
1337        fn init_metric(&self) -> &PhaseMetric {
1338            &self.init_metric
1339        }
1340    }
1341
1342    fn value(value: serde_json::Value) -> AwsCliValue {
1343        AwsCliValue::from_json(value)
1344    }
1345
1346    fn request(service: &str, operation: &str, parameters: serde_json::Value) -> AwsCliCommand {
1347        let parameters: HashMap<String, AwsCliValue> = parameters
1348            .as_object()
1349            .expect("test parameters must be an object")
1350            .iter()
1351            .map(|(name, value)| (name.clone(), AwsCliValue::from_json(value.clone())))
1352            .collect();
1353        AwsCliCommand::new(service, operation, parameters).with_http_method("POST")
1354    }
1355
1356    fn synthesized_json(request: &AwsCliCommand) -> (Classification, Synthesis, serde_json::Value) {
1357        let schema_validator = SchemaValidator::default();
1358        let classification = classify_operation(request, &schema_validator).expect("classification succeeds");
1359        let synthesis = synthesize_request(request, &classification, &schema_validator).expect("synthesis succeeds");
1360        let template = synthesis.template.as_ref().expect("request must synthesize");
1361        let document = serde_json::from_slice(template).expect("template must be JSON");
1362        (classification, synthesis, document)
1363    }
1364
1365    fn mapping(source: &str, target: &str) -> PropertyMapping {
1366        PropertyMapping { source: source.into(), target: target.into(), unrepresentable: None }
1367    }
1368
1369    fn s3_create_bucket_adapter(mappings: Vec<PropertyMapping>) -> OperationAdapter {
1370        OperationAdapter {
1371            service: "s3".into(),
1372            operation: "CreateBucket".into(),
1373            phase: AdapterPhase::Create,
1374            cfn_type: "AWS::S3::Bucket".into(),
1375            mappings,
1376            ignored_inputs: Vec::new(),
1377        }
1378    }
1379
1380    fn map_s3_create_bucket(adapter: &OperationAdapter, parameters: serde_json::Value) -> AdapterMappingResult {
1381        let schema_validator = SchemaValidator::default();
1382        let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
1383        let parameters: HashMap<String, AwsCliValue> = parameters
1384            .as_object()
1385            .expect("test parameters must be an object")
1386            .iter()
1387            .map(|(name, value)| (name.clone(), AwsCliValue::from_json(value.clone())))
1388            .collect();
1389        map_adapter_properties(&parameters, &schema, adapter).expect("well-formed adapter must not error")
1390    }
1391
1392    fn skip_reason(result: AdapterMappingResult) -> String {
1393        match result {
1394            AdapterMappingResult::Skip(reason) => reason,
1395            AdapterMappingResult::Mapped(properties) => panic!("expected skip, got mapped properties {properties:?}"),
1396        }
1397    }
1398
1399    fn malformed_adapter_error(mappings: Vec<PropertyMapping>) -> String {
1400        let schema_validator = SchemaValidator::default();
1401        let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
1402        let adapter = s3_create_bucket_adapter(mappings);
1403        match map_adapter_properties(&HashMap::new(), &schema, &adapter)
1404            .expect_err("malformed adapter must return an error")
1405        {
1406            ValidationError::Engine(message) => message,
1407            error => panic!("expected engine error, got {error:?}"),
1408        }
1409    }
1410
1411    #[test]
1412    fn catalog_parser_reads_unrepresentable_domains() {
1413        let catalog = br#"{
1414            "format_version": 1,
1415            "adapters": [{
1416                "service": "s3", "operation": "CreateBucket", "phase": "create", "cfn_type": "AWS::S3::Bucket",
1417                "mappings": [
1418                    {"source": "Bucket", "target": "BucketName",
1419                     "unrepresentable": {"rejected_values": ["legacy"], "min_length": 3, "max_length": 63}},
1420                    {"source": "Tags", "target": "Tags",
1421                     "unrepresentable": {"tag_value": {"min_length": 1}, "items": {"maximum": 10.0}}},
1422                    {"source": "Region", "target": "Region",
1423                     "unrepresentable": {"pattern": {"api": "^[a-z]{2,4}$", "cloudformation": "^[a-z]{2}$"}}}
1424                ]
1425            }]
1426        }"#;
1427        let registry = parse_adapter_registry(catalog).expect("catalog with domains parses");
1428        let adapter = &registry[&("s3".to_string(), "CreateBucket".to_string())];
1429        assert_eq!(
1430            adapter.mappings[0].unrepresentable,
1431            Some(UnrepresentableDomain {
1432                rejected_values: vec![serde_json::json!("legacy")],
1433                min_length: Some(3),
1434                max_length: Some(63),
1435                ..UnrepresentableDomain::default()
1436            })
1437        );
1438        let tags = adapter.mappings[1].unrepresentable.as_ref().expect("tags domain parses");
1439        assert_eq!(tags.tag_value.as_deref().and_then(|domain| domain.min_length), Some(1));
1440        assert_eq!(tags.items.as_deref().and_then(|domain| domain.maximum), Some(10.0));
1441        assert_eq!(
1442            adapter.mappings[2].unrepresentable.as_ref().and_then(|domain| domain.pattern.clone()),
1443            Some(PatternDivergence { api: Some("^[a-z]{2,4}$".into()), cloudformation: "^[a-z]{2}$".into() })
1444        );
1445    }
1446
1447    #[test]
1448    fn pattern_divergence_rejects_only_values_the_api_accepts_and_cloudformation_rejects() {
1449        let sqs_arn = UnrepresentableDomain {
1450            pattern: Some(PatternDivergence {
1451                api: Some(r"^arn:aws[a-z-]*:sqs:[a-z]{2,4}(-[a-z]+)+-\d:\d{12}:.+$".into()),
1452                cloudformation: r"^arn:aws[a-z-]*:sqs:[a-z]{2}(-[a-z]+)+-\d:\d{12}:.+$".into(),
1453            }),
1454            ..UnrepresentableDomain::default()
1455        };
1456        let sovereign = value(serde_json::json!("arn:aws-eusc:sqs:eusc-de-east-1:123456789012:queue"));
1457        let rejection = sqs_arn.rejection(&sovereign).expect("domain evaluates").expect("value is unrepresentable");
1458        assert!(rejection.contains("CloudFormation pattern"), "{rejection}");
1459        assert!(rejection.contains(API_DOES_NOT_ENFORCE), "{rejection}");
1460        assert!(
1461            sqs_arn
1462                .rejection(&value(serde_json::json!("arn:aws:sqs:us-east-1:123456789012:queue")))
1463                .expect("domain evaluates")
1464                .is_none(),
1465            "a value both patterns accept is representable"
1466        );
1467        assert!(
1468            sqs_arn.rejection(&value(serde_json::json!("not-an-arn"))).expect("domain evaluates").is_none(),
1469            "a value both patterns reject keeps its CloudFormation finding"
1470        );
1471        assert!(
1472            sqs_arn
1473                .rejection(&value(serde_json::json!(" arn:aws:sqs:eusc-de-east-1:123456789012:q")))
1474                .expect("domain evaluates")
1475                .is_none(),
1476            "the API pattern applies to the whole value"
1477        );
1478
1479        let nested = UnrepresentableDomain {
1480            items: Some(Box::new(sqs_arn.clone())),
1481            tag_value: Some(Box::new(sqs_arn)),
1482            ..UnrepresentableDomain::default()
1483        };
1484        assert!(
1485            nested
1486                .rejection(&value(serde_json::json!([sovereign.json_value().unwrap()])))
1487                .expect("domain evaluates")
1488                .is_some()
1489        );
1490        assert!(
1491            nested
1492                .rejection(&value(serde_json::json!({"Queue": sovereign.json_value().unwrap()})))
1493                .expect("domain evaluates")
1494                .is_some()
1495        );
1496    }
1497
1498    #[test]
1499    fn cloudformation_only_pattern_rejects_every_value_outside_it() {
1500        let table_name = UnrepresentableDomain {
1501            pattern: Some(PatternDivergence { api: None, cloudformation: "^[a-zA-Z0-9_.-]+$".into() }),
1502            ..UnrepresentableDomain::default()
1503        };
1504        let table_arn = value(serde_json::json!("arn:aws:dynamodb:us-east-1:123456789012:table/Orders"));
1505        let rejection = table_name.rejection(&table_arn).expect("domain evaluates").expect("ARN is unrepresentable");
1506        assert!(rejection.contains("does not match the CloudFormation pattern"), "{rejection}");
1507        assert!(rejection.contains(API_DOES_NOT_ENFORCE), "{rejection}");
1508        assert!(
1509            table_name.rejection(&value(serde_json::json!("Orders"))).expect("domain evaluates").is_none(),
1510            "a value inside the CloudFormation pattern is representable"
1511        );
1512    }
1513
1514    #[test]
1515    fn cloudformation_only_enum_rejects_every_value_outside_it() {
1516        let data_type = UnrepresentableDomain {
1517            allowed_values: vec![serde_json::json!("text"), serde_json::json!("aws:ec2:image")],
1518            ..UnrepresentableDomain::default()
1519        };
1520        let rejection = data_type
1521            .rejection(&value(serde_json::json!("aws:ssm:integration")))
1522            .expect("domain evaluates")
1523            .expect("value outside the CloudFormation enum is unrepresentable");
1524        assert!(rejection.contains("is not one of the CloudFormation values"), "{rejection}");
1525        assert!(rejection.contains("aws:ec2:image"), "{rejection}");
1526        assert!(rejection.contains(API_DOES_NOT_ENFORCE), "{rejection}");
1527        assert!(data_type.rejection(&value(serde_json::json!("text"))).expect("domain evaluates").is_none());
1528        assert!(
1529            data_type.rejection(&value(serde_json::json!(7))).expect("domain evaluates").is_some(),
1530            "a non-string value is outside a string enum"
1531        );
1532    }
1533
1534    #[test]
1535    fn tag_map_entry_count_outside_cloudformation_item_bounds_is_unrepresentable() {
1536        let tags =
1537            UnrepresentableDomain { min_length: Some(1), max_length: Some(2), ..UnrepresentableDomain::default() };
1538        let empty = tags.rejection(&value(serde_json::json!({}))).expect("domain evaluates").expect("empty map");
1539        assert!(empty.contains("entry count 0"), "{empty}");
1540        assert!(empty.contains("minimum 1"), "{empty}");
1541        let crowded = tags
1542            .rejection(&value(serde_json::json!({"a": "1", "b": "2", "c": "3"})))
1543            .expect("domain evaluates")
1544            .expect("three entries above the maximum");
1545        assert!(crowded.contains("entry count 3"), "{crowded}");
1546        assert!(crowded.contains("maximum 2"), "{crowded}");
1547        assert!(tags.rejection(&value(serde_json::json!({"a": "1"}))).expect("domain evaluates").is_none());
1548    }
1549
1550    #[test]
1551    fn aws_cli_config_is_fixed_at_standard_detail_and_warn_severity() {
1552        let preset = aws_cli_validate_config();
1553        assert_eq!(preset.detail_level, DetailLevel::Standard);
1554        assert_eq!(preset.severity_level, Severity::Warn);
1555        assert!(!preset.strict);
1556        assert!(!preset.disable_builtin_rules);
1557    }
1558
1559    #[test]
1560    fn synthesized_create_reports_omit_info_findings() {
1561        let schema_validator = SchemaValidator::default();
1562        let engine = NoopEngine::default();
1563        let create_bucket = request("s3", "CreateBucket", serde_json::json!({"Bucket": "my-bucket"}));
1564        let validation =
1565            validate_aws_cli_command(&engine, &schema_validator, &create_bucket).expect("validation succeeds");
1566        let report = validation.report.expect("bucket request is validated");
1567        assert_eq!(report.metadata.severity_level, Severity::Warn);
1568        assert!(
1569            report.diagnostics.iter().all(|diagnostic| diagnostic.severity >= Severity::Warn),
1570            "info findings must be gated: {:?}",
1571            report.diagnostics
1572        );
1573        assert!(
1574            report.metadata.suppressed >= 1,
1575            "the create-only replacement note on BucketName is an Info finding that the floor removes"
1576        );
1577    }
1578
1579    #[test]
1580    fn uncompilable_pattern_divergence_is_a_catalog_error_not_a_silent_skip() {
1581        let broken = UnrepresentableDomain {
1582            pattern: Some(PatternDivergence { api: Some("^(unclosed$".into()), cloudformation: "^[a-z]+$".into() }),
1583            ..UnrepresentableDomain::default()
1584        };
1585        let error = broken.rejection(&value(serde_json::json!("abc"))).expect_err("broken pattern must error");
1586        assert!(error.contains("cannot be compiled"), "{error}");
1587
1588        let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1589            source: "Bucket".into(),
1590            target: "BucketName".into(),
1591            unrepresentable: Some(broken),
1592        }]);
1593        let schema_validator = SchemaValidator::default();
1594        let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
1595        let parameters = HashMap::from([("Bucket".to_string(), value(serde_json::json!("abc")))]);
1596        match map_adapter_properties(&parameters, &schema, &adapter).expect_err("unevaluable domain must error") {
1597            ValidationError::Engine(message) => {
1598                assert!(message.contains("unevaluable unrepresentable domain"), "{message}")
1599            }
1600            error => panic!("expected engine error, got {error:?}"),
1601        }
1602    }
1603
1604    #[test]
1605    fn adapter_skips_when_supplied_value_diverges_from_the_cloudformation_pattern() {
1606        let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1607            source: "Bucket".into(),
1608            target: "BucketName".into(),
1609            unrepresentable: Some(UnrepresentableDomain {
1610                pattern: Some(PatternDivergence {
1611                    api: Some("^[a-z0-9.-]+$".into()),
1612                    cloudformation: "^[a-z0-9-]+$".into(),
1613                }),
1614                ..UnrepresentableDomain::default()
1615            }),
1616        }]);
1617        let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "my.bucket"})));
1618        assert!(reason.contains("parameter 'Bucket'"), "{reason}");
1619        assert!(reason.contains("does not match the CloudFormation pattern"), "{reason}");
1620        match map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "my-bucket"})) {
1621            AdapterMappingResult::Mapped(properties) => {
1622                assert_eq!(properties.get("BucketName"), Some(&serde_json::json!("my-bucket")));
1623            }
1624            AdapterMappingResult::Skip(reason) => panic!("value inside both patterns must map: {reason}"),
1625        }
1626    }
1627
1628    fn collect_api_patterns<'a>(domain: &'a UnrepresentableDomain, patterns: &mut Vec<&'a str>) {
1629        if let Some(divergence) = domain.pattern.as_ref() {
1630            patterns.extend(divergence.api.as_deref());
1631        }
1632        for nested in [&domain.items, &domain.tag_key, &domain.tag_value].into_iter().flatten() {
1633            collect_api_patterns(nested, patterns);
1634        }
1635    }
1636
1637    #[test]
1638    fn every_api_pattern_in_the_embedded_catalog_compiles() {
1639        let mut patterns = Vec::new();
1640        for adapter in adapter_registry().expect("embedded catalog parses").values() {
1641            for mapping in &adapter.mappings {
1642                if let Some(domain) = mapping.unrepresentable.as_ref() {
1643                    collect_api_patterns(domain, &mut patterns);
1644                }
1645            }
1646        }
1647        let uncompilable: Vec<&str> =
1648            patterns.iter().copied().filter(|pattern| compile_pattern(pattern).is_none()).collect();
1649        assert!(uncompilable.is_empty(), "catalog API patterns must compile: {uncompilable:?}");
1650    }
1651
1652    #[test]
1653    fn uncompilable_cloudformation_pattern_rejects_nothing_like_the_schema_validator() {
1654        let unclosed_group = UnrepresentableDomain {
1655            pattern: Some(PatternDivergence { api: None, cloudformation: "^(unclosed$".into() }),
1656            ..UnrepresentableDomain::default()
1657        };
1658        assert!(
1659            unclosed_group.rejection(&value(serde_json::json!("anything"))).expect("domain evaluates").is_none(),
1660            "a pattern the schema validator cannot apply produces no finding to guard against"
1661        );
1662    }
1663
1664    #[test]
1665    fn unrepresentable_domain_rejects_only_values_outside_cloudformation_bounds() {
1666        let enumeration = UnrepresentableDomain {
1667            rejected_values: vec![serde_json::json!("MANAGED_INSTANCES")],
1668            ..UnrepresentableDomain::default()
1669        };
1670        assert!(
1671            enumeration.rejection(&value(serde_json::json!("MANAGED_INSTANCES"))).expect("domain evaluates").is_some()
1672        );
1673        assert!(enumeration.rejection(&value(serde_json::json!("FARGATE"))).expect("domain evaluates").is_none());
1674
1675        let numeric =
1676            UnrepresentableDomain { minimum: Some(1.0), maximum: Some(10.0), ..UnrepresentableDomain::default() };
1677        assert!(numeric.rejection(&value(serde_json::json!(0))).expect("domain evaluates").is_some());
1678        assert!(numeric.rejection(&value(serde_json::json!(50))).expect("domain evaluates").is_some());
1679        assert!(numeric.rejection(&value(serde_json::json!(10))).expect("domain evaluates").is_none());
1680        assert!(numeric.rejection(&value(serde_json::json!(1))).expect("domain evaluates").is_none());
1681        assert!(numeric.rejection(&AwsCliValue::UnsignedInteger { value: 11 }).expect("domain evaluates").is_some());
1682        assert!(numeric.rejection(&AwsCliValue::Number { value: 0.5 }).expect("domain evaluates").is_some());
1683
1684        let text_length =
1685            UnrepresentableDomain { min_length: Some(5), max_length: Some(6), ..UnrepresentableDomain::default() };
1686        assert!(text_length.rejection(&value(serde_json::json!("abc"))).expect("domain evaluates").is_some());
1687        assert!(text_length.rejection(&value(serde_json::json!("abcdefg"))).expect("domain evaluates").is_some());
1688        assert!(text_length.rejection(&value(serde_json::json!("abcde"))).expect("domain evaluates").is_none());
1689        assert!(
1690            text_length.rejection(&value(serde_json::json!("ééééé"))).expect("domain evaluates").is_none(),
1691            "length counts characters"
1692        );
1693
1694        let list = UnrepresentableDomain {
1695            max_length: Some(2),
1696            items: Some(Box::new(UnrepresentableDomain {
1697                rejected_values: vec![serde_json::json!("SUPER_USER")],
1698                ..UnrepresentableDomain::default()
1699            })),
1700            ..UnrepresentableDomain::default()
1701        };
1702        assert!(
1703            list.rejection(&value(serde_json::json!(["a", "b", "c"]))).expect("domain evaluates").is_some(),
1704            "item count above maximum"
1705        );
1706        assert!(
1707            list.rejection(&value(serde_json::json!(["SELECT", "SUPER_USER"]))).expect("domain evaluates").is_some(),
1708            "rejected item"
1709        );
1710        assert!(list.rejection(&value(serde_json::json!(["SELECT", "ALTER"]))).expect("domain evaluates").is_none());
1711
1712        let tags = UnrepresentableDomain {
1713            tag_key: Some(Box::new(UnrepresentableDomain { max_length: Some(3), ..UnrepresentableDomain::default() })),
1714            tag_value: Some(Box::new(UnrepresentableDomain {
1715                min_length: Some(1),
1716                ..UnrepresentableDomain::default()
1717            })),
1718            ..UnrepresentableDomain::default()
1719        };
1720        assert!(
1721            tags.rejection(&value(serde_json::json!({"Team": "cli"}))).expect("domain evaluates").is_some(),
1722            "key too long"
1723        );
1724        assert!(
1725            tags.rejection(&value(serde_json::json!({"Env": ""}))).expect("domain evaluates").is_some(),
1726            "empty value"
1727        );
1728        assert!(tags.rejection(&value(serde_json::json!({"Env": "prod"}))).expect("domain evaluates").is_none());
1729
1730        let empty = UnrepresentableDomain::default();
1731        assert!(empty.rejection(&value(serde_json::json!(true))).expect("domain evaluates").is_none());
1732        assert!(empty.rejection(&value(serde_json::json!(null))).expect("domain evaluates").is_none());
1733    }
1734
1735    #[test]
1736    fn adapter_skips_when_supplied_value_is_valid_for_the_api_but_not_for_cloudformation() {
1737        let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1738            source: "Bucket".into(),
1739            target: "BucketName".into(),
1740            unrepresentable: Some(UnrepresentableDomain {
1741                rejected_values: vec![serde_json::json!("legacy")],
1742                min_length: Some(3),
1743                ..UnrepresentableDomain::default()
1744            }),
1745        }]);
1746
1747        let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "legacy"})));
1748        assert!(reason.contains("parameter 'Bucket'"), "{reason}");
1749        assert!(reason.contains("is outside the CloudFormation enum"), "{reason}");
1750        assert!(reason.contains(API_DOES_NOT_ENFORCE), "{reason}");
1751        assert!(reason.contains("property 'BucketName' on AWS::S3::Bucket"), "{reason}");
1752
1753        let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "ab"})));
1754        assert!(reason.contains("length 2"), "{reason}");
1755        assert!(reason.contains("minimum 3"), "{reason}");
1756
1757        match map_s3_create_bucket(&adapter, serde_json::json!({"Bucket": "my-bucket"})) {
1758            AdapterMappingResult::Mapped(properties) => {
1759                assert_eq!(properties.get("BucketName"), Some(&serde_json::json!("my-bucket")));
1760            }
1761            AdapterMappingResult::Skip(reason) => panic!("representable value must map: {reason}"),
1762        }
1763    }
1764
1765    #[test]
1766    fn adapter_skips_tag_maps_whose_entries_have_no_cloudformation_representation() {
1767        let adapter = s3_create_bucket_adapter(vec![PropertyMapping {
1768            source: "Tags".into(),
1769            target: "Tags".into(),
1770            unrepresentable: Some(UnrepresentableDomain {
1771                tag_value: Some(Box::new(UnrepresentableDomain {
1772                    min_length: Some(1),
1773                    ..UnrepresentableDomain::default()
1774                })),
1775                ..UnrepresentableDomain::default()
1776            }),
1777        }]);
1778
1779        let reason = skip_reason(map_s3_create_bucket(&adapter, serde_json::json!({"Tags": {"Env": ""}})));
1780        assert!(reason.contains("parameter 'Tags'"), "{reason}");
1781        assert!(reason.contains("length 0"), "{reason}");
1782
1783        match map_s3_create_bucket(&adapter, serde_json::json!({"Tags": {"Env": "prod"}})) {
1784            AdapterMappingResult::Mapped(properties) => {
1785                assert_eq!(properties.get("Tags"), Some(&serde_json::json!([{"Key": "Env", "Value": "prod"}])));
1786            }
1787            AdapterMappingResult::Skip(reason) => panic!("representable tags must map: {reason}"),
1788        }
1789    }
1790    #[test]
1791    fn catalog_parser_rejects_invalid_formats_and_normalized_duplicates() {
1792        assert!(parse_adapter_registry(b"not json").expect_err("invalid JSON must fail").contains("invalid"));
1793        assert!(
1794            parse_adapter_registry(br#"{"format_version":2,"adapters":[]}"#)
1795                .expect_err("unsupported format must fail")
1796                .contains("unsupported")
1797        );
1798        let duplicate = br#"{
1799            "format_version": 1,
1800            "adapters": [
1801                {"service":"S3","operation":"CreateBucket","phase":"create","cfn_type":"AWS::S3::Bucket","mappings":[]},
1802                {"service":"s3","operation":"CreateBucket","phase":"create","cfn_type":"AWS::S3::Bucket","mappings":[]}
1803            ]
1804        }"#;
1805        assert!(
1806            parse_adapter_registry(duplicate).expect_err("case-normalized duplicate must fail").contains("duplicate")
1807        );
1808        let blank = br#"{
1809            "format_version": 1,
1810            "adapters": [
1811                {"service":"","operation":"CreateBucket","phase":"create","cfn_type":"AWS::S3::Bucket","mappings":[]}
1812            ]
1813        }"#;
1814        assert!(parse_adapter_registry(blank).expect_err("blank identity must fail").contains("blank"));
1815    }
1816
1817    #[test]
1818    fn catalog_covers_the_generated_resource_universe() {
1819        let registry = adapter_registry().expect("catalog loads");
1820        let creates = registry.values().filter(|a| a.phase == AdapterPhase::Create).count();
1821        let deletes = registry.values().filter(|a| a.phase == AdapterPhase::Delete).count();
1822        assert!(registry.len() >= 2000, "catalog unexpectedly small: {}", registry.len());
1823        assert!(creates >= 1000, "create adapters unexpectedly few: {creates}");
1824        assert!(deletes >= 900, "delete adapters unexpectedly few: {deletes}");
1825    }
1826
1827    #[test]
1828    fn catalog_operations_synthesize_beyond_the_original_services() {
1829        let cases = [
1830            ("ec2", "RunInstances", "AWS::EC2::Instance"),
1831            ("kms", "CreateKey", "AWS::KMS::Key"),
1832            ("logs", "CreateLogGroup", "AWS::Logs::LogGroup"),
1833            ("stepfunctions", "CreateStateMachine", "AWS::StepFunctions::StateMachine"),
1834            ("cloudwatch", "PutMetricAlarm", "AWS::CloudWatch::Alarm"),
1835            ("secretsmanager", "CreateSecret", "AWS::SecretsManager::Secret"),
1836        ];
1837        let schema_validator = SchemaValidator::default();
1838        for (service, operation, expected_type) in cases {
1839            let req = request(service, operation, serde_json::json!({}));
1840            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
1841            assert_eq!(classification.candidates, [expected_type], "{service}:{operation} must map to {expected_type}");
1842        }
1843    }
1844
1845    #[test]
1846    fn catalog_never_contains_forbidden_or_ambiguous_operations() {
1847        let forbidden = [
1848            ("ecs", "RunTask"),
1849            ("ec2", "StartInstances"),
1850            ("ec2", "StopInstances"),
1851            ("iot", "StartThingRegistrationTask"),
1852            ("lambda", "Invoke"),
1853            ("sns", "Publish"),
1854            ("sqs", "SendMessage"),
1855            ("s3", "PutObject"),
1856            ("dynamodb", "PutItem"),
1857            ("logs", "StartQuery"),
1858            ("acm", "RemoveTagsFromCertificate"),
1859            ("robomaker", "DeregisterRobot"),
1860            ("quicksight", "CreateTopic"),
1861            ("quicksight", "DeleteTopic"),
1862        ];
1863        let registry = adapter_registry().expect("catalog loads");
1864        for (service, operation) in forbidden {
1865            let key = (service.to_string(), operation.to_string());
1866            assert!(!registry.contains_key(&key), "{service}:{operation} must never be a registered adapter");
1867        }
1868    }
1869
1870    #[test]
1871    fn every_catalog_adapter_maps_cleanly_with_empty_requests() {
1872        let schema_validator = SchemaValidator::default();
1873        let empty = HashMap::new();
1874        for adapter in adapter_registry().expect("catalog loads").values() {
1875            let schema = schema_validator
1876                .resource_schema_metadata(&adapter.cfn_type)
1877                .unwrap_or_else(|| panic!("{} missing schema metadata", adapter.cfn_type));
1878            let result = map_adapter_properties(&empty, &schema, adapter).unwrap_or_else(|error| {
1879                panic!("adapter {}:{} violates registry invariants: {error}", adapter.service, adapter.operation)
1880            });
1881            // Empty parameters always produce Mapped (no supplied params to conflict).
1882            assert!(
1883                matches!(result, AdapterMappingResult::Mapped(_)),
1884                "adapter {}:{} must accept empty parameters",
1885                adapter.service,
1886                adapter.operation
1887            );
1888        }
1889    }
1890
1891    #[test]
1892    fn nested_values_are_rejected_without_recursive_shape_mappings() {
1893        let object_types = BTreeSet::from([PropertyValueType::Object]);
1894        let array_types = BTreeSet::from([PropertyValueType::Array]);
1895        let object = AwsCliValue::from_json(serde_json::json!({"lowerCamel": "value"}));
1896        let object_array = AwsCliValue::from_json(serde_json::json!([{"lowerCamel": "value"}]));
1897        let scalar_array = AwsCliValue::from_json(serde_json::json!(["one", "two"]));
1898
1899        assert!(mapped_value(&object, &object_types, "Configuration").is_none());
1900        assert!(mapped_value(&object_array, &array_types, "Configurations").is_none());
1901        assert_eq!(mapped_value(&scalar_array, &array_types, "Names"), Some(serde_json::json!(["one", "two"])));
1902    }
1903
1904    #[test]
1905    fn registry_has_unique_service_operation_keys() {
1906        let mut seen = BTreeSet::new();
1907        for adapter in adapter_registry().expect("catalog loads").values() {
1908            let key = (normalize_service(&adapter.service), adapter.operation.clone());
1909            assert!(seen.insert(key.clone()), "duplicate adapter key: {}:{}", key.0, key.1);
1910        }
1911    }
1912
1913    #[test]
1914    fn registry_types_exist_in_schema_validator() {
1915        let schema_validator = SchemaValidator::default();
1916        for adapter in adapter_registry().expect("catalog loads").values() {
1917            assert!(
1918                schema_validator.has_resource_type(&adapter.cfn_type),
1919                "adapter {}:{} references unknown type {}",
1920                adapter.service,
1921                adapter.operation,
1922                adapter.cfn_type
1923            );
1924        }
1925    }
1926
1927    #[test]
1928    fn registry_property_mappings_target_real_properties() {
1929        let schema_validator = SchemaValidator::default();
1930        for adapter in adapter_registry().expect("catalog loads").values() {
1931            let Some(schema) = schema_validator.resource_schema_metadata(&adapter.cfn_type) else {
1932                continue;
1933            };
1934            for mapping in &adapter.mappings {
1935                assert!(
1936                    schema.property_types.contains_key(&mapping.target),
1937                    "adapter {}:{} maps to non-existent property {}.{}",
1938                    adapter.service,
1939                    adapter.operation,
1940                    adapter.cfn_type,
1941                    mapping.target
1942                );
1943            }
1944        }
1945    }
1946
1947    #[test]
1948    fn registry_has_no_read_only_property_mappings() {
1949        let schema_validator = SchemaValidator::default();
1950        for adapter in adapter_registry().expect("catalog loads").values() {
1951            let Some(schema) = schema_validator.resource_schema_metadata(&adapter.cfn_type) else {
1952                continue;
1953            };
1954            for mapping in &adapter.mappings {
1955                assert!(
1956                    !schema.read_only_properties.contains(&mapping.target),
1957                    "adapter {}:{} maps to read-only property {}.{}",
1958                    adapter.service,
1959                    adapter.operation,
1960                    adapter.cfn_type,
1961                    mapping.target
1962                );
1963            }
1964        }
1965    }
1966
1967    #[test]
1968    fn registry_update_mappings_exclude_primary_identifiers() {
1969        let schema_validator = SchemaValidator::default();
1970        for adapter in adapter_registry().expect("catalog loads").values() {
1971            if adapter.phase != AdapterPhase::Update {
1972                continue;
1973            }
1974            let Some(schema) = schema_validator.resource_schema_metadata(&adapter.cfn_type) else {
1975                continue;
1976            };
1977            for mapping in &adapter.mappings {
1978                assert!(
1979                    !schema.primary_identifier_properties.contains(&mapping.target),
1980                    "update adapter {}:{} maps to primary identifier property {}.{}",
1981                    adapter.service,
1982                    adapter.operation,
1983                    adapter.cfn_type,
1984                    mapping.target
1985                );
1986            }
1987        }
1988    }
1989
1990    #[test]
1991    fn registry_has_no_duplicate_source_or_target_mappings() {
1992        for adapter in adapter_registry().expect("catalog loads").values() {
1993            let mut sources = BTreeSet::new();
1994            let mut targets = BTreeSet::new();
1995            for mapping in &adapter.mappings {
1996                assert!(
1997                    sources.insert(mapping.source.as_str()),
1998                    "adapter {}:{} has duplicate source mapping: {}",
1999                    adapter.service,
2000                    adapter.operation,
2001                    mapping.source
2002                );
2003                assert!(
2004                    targets.insert(mapping.target.as_str()),
2005                    "adapter {}:{} has duplicate target mapping: {}",
2006                    adapter.service,
2007                    adapter.operation,
2008                    mapping.target
2009                );
2010            }
2011        }
2012    }
2013
2014    #[test]
2015    fn malformed_adapter_mappings_fail_without_request_values() {
2016        assert!(malformed_adapter_error(vec![mapping("Bucket", "NotAProperty")]).contains("does not exist"));
2017        assert!(malformed_adapter_error(vec![mapping("Bucket", "Arn")]).contains("excluded property"));
2018        assert!(
2019            malformed_adapter_error(vec![mapping("Bucket", "BucketName"), mapping("Bucket", "Tags")])
2020                .contains("duplicate source parameter")
2021        );
2022        assert!(
2023            malformed_adapter_error(vec![mapping("Bucket", "BucketName"), mapping("OtherBucket", "BucketName")])
2024                .contains("duplicate target property")
2025        );
2026    }
2027
2028    #[test]
2029    fn s3_create_bucket_synthesizes_with_explicit_mappings() {
2030        let request = request("s3", "CreateBucket", serde_json::json!({"Bucket": "synthetic-bucket"}));
2031        let (classification, synthesis, document) = synthesized_json(&request);
2032        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2033        assert_eq!(classification.candidates, ["AWS::S3::Bucket"]);
2034        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2035        assert_eq!(document["Resources"]["Resource"]["Properties"]["BucketName"], "synthetic-bucket");
2036    }
2037
2038    #[test]
2039    fn s3_delete_bucket_identifies_type_without_synthesizing() {
2040        let schema_validator = SchemaValidator::default();
2041        let request = request("s3", "DeleteBucket", serde_json::json!({"Bucket": "synthetic-bucket"}));
2042        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2043        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2044        assert_eq!(classification.candidates, ["AWS::S3::Bucket"]);
2045        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2046        assert!(synthesis.template.is_none());
2047    }
2048
2049    #[test]
2050    fn dynamodb_create_table_skips_when_unmapped_nested_parameters_are_present() {
2051        let schema_validator = SchemaValidator::default();
2052        let request = request(
2053            "dynamodb",
2054            "CreateTable",
2055            serde_json::json!({
2056                "TableName": "Synthetic",
2057                "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
2058                "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}],
2059                "BillingMode": "PAY_PER_REQUEST"
2060            }),
2061        );
2062        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2063        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2064        assert_eq!(classification.candidates, ["AWS::DynamoDB::Table"]);
2065        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2066        assert!(synthesis.template.is_none(), "unmapped nested parameters must skip synthesis");
2067        assert!(
2068            synthesis.reason.contains("has no mapping"),
2069            "reason must explain the unmapped parameter: {}",
2070            synthesis.reason
2071        );
2072    }
2073
2074    #[test]
2075    fn dynamodb_create_table_synthesizes_with_scalar_only_parameters() {
2076        let request = request(
2077            "dynamodb",
2078            "CreateTable",
2079            serde_json::json!({"TableName": "Synthetic", "BillingMode": "PAY_PER_REQUEST"}),
2080        );
2081        let (classification, synthesis, document) = synthesized_json(&request);
2082        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2083        assert_eq!(classification.candidates, ["AWS::DynamoDB::Table"]);
2084        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2085        assert_eq!(document["Resources"]["Resource"]["Properties"]["TableName"], "Synthetic");
2086        assert_eq!(document["Resources"]["Resource"]["Properties"]["BillingMode"], "PAY_PER_REQUEST");
2087    }
2088
2089    #[test]
2090    fn dynamodb_delete_table_identifies_type_without_synthesizing() {
2091        let schema_validator = SchemaValidator::default();
2092        let request = request("dynamodb", "DeleteTable", serde_json::json!({"TableName": "Synthetic"}));
2093        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2094        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2095        assert_eq!(classification.candidates, ["AWS::DynamoDB::Table"]);
2096        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2097        assert!(synthesis.template.is_none());
2098    }
2099
2100    #[test]
2101    fn iam_create_role_synthesizes_with_explicit_mappings() {
2102        let request = request(
2103            "iam",
2104            "CreateRole",
2105            serde_json::json!({
2106                "RoleName": "Synthetic",
2107                "AssumeRolePolicyDocument": "{\"Version\":\"2012-10-17\",\"Statement\":[]}"
2108            }),
2109        );
2110        let (classification, synthesis, document) = synthesized_json(&request);
2111        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2112        assert_eq!(classification.candidates, ["AWS::IAM::Role"]);
2113        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2114        assert_eq!(document["Resources"]["Resource"]["Properties"]["RoleName"], "Synthetic");
2115    }
2116
2117    #[test]
2118    fn iam_delete_role_identifies_type_without_synthesizing() {
2119        let schema_validator = SchemaValidator::default();
2120        let request = request("iam", "DeleteRole", serde_json::json!({"RoleName": "Synthetic"}));
2121        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2122        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2123        assert_eq!(classification.candidates, ["AWS::IAM::Role"]);
2124        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2125        assert!(synthesis.template.is_none());
2126    }
2127
2128    #[test]
2129    fn lambda_create_function_synthesizes_all_supplied_scalar_properties() {
2130        let request =
2131            request("lambda", "CreateFunction", serde_json::json!({"FunctionName": "Synthetic", "MemorySize": 128}));
2132        let (classification, synthesis, document) = synthesized_json(&request);
2133        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2134        assert_eq!(classification.candidates, ["AWS::Lambda::Function"]);
2135        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2136        assert_eq!(synthesis.diagnostic_properties, Some(BTreeSet::from(["FunctionName".into(), "MemorySize".into()])));
2137        assert_eq!(document["Resources"]["Resource"]["Properties"]["FunctionName"], "Synthetic");
2138        assert_eq!(document["Resources"]["Resource"]["Properties"]["MemorySize"], 128);
2139    }
2140
2141    #[test]
2142    fn lambda_update_function_configuration_maps_all_supplied_mutable_properties() {
2143        let request = request(
2144            "lambda",
2145            "UpdateFunctionConfiguration",
2146            serde_json::json!({"FunctionName": "Synthetic", "MemorySize": 128}),
2147        );
2148        let (classification, synthesis, document) = synthesized_json(&request);
2149        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationUpdate);
2150        assert_eq!(classification.candidates, ["AWS::Lambda::Function"]);
2151        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedUpdate));
2152        assert_eq!(document["Resources"]["Resource"]["Properties"], serde_json::json!({"MemorySize": 128}));
2153        assert_eq!(synthesis.diagnostic_properties, Some(BTreeSet::from(["MemorySize".into()])));
2154    }
2155
2156    #[test]
2157    fn lambda_delete_function_identifies_type_without_synthesizing() {
2158        let schema_validator = SchemaValidator::default();
2159        let request = request("lambda", "DeleteFunction", serde_json::json!({"FunctionName": "Synthetic"}));
2160        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2161        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2162        assert_eq!(classification.candidates, ["AWS::Lambda::Function"]);
2163        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2164        assert!(synthesis.template.is_none());
2165    }
2166
2167    #[test]
2168    fn sns_create_topic_synthesizes_with_explicit_mappings() {
2169        let request = request("sns", "CreateTopic", serde_json::json!({"Name": "Synthetic"}));
2170        let (classification, synthesis, document) = synthesized_json(&request);
2171        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2172        assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2173        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2174        assert_eq!(document["Resources"]["Resource"]["Properties"]["TopicName"], "Synthetic");
2175    }
2176
2177    #[test]
2178    fn sns_delete_topic_identifies_type_without_synthesizing() {
2179        let schema_validator = SchemaValidator::default();
2180        let request = request("sns", "DeleteTopic", serde_json::json!({"TopicArn": "arn:aws:sns:us-east-1:123:Topic"}));
2181        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2182        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2183        assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2184        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2185        assert!(synthesis.template.is_none());
2186    }
2187
2188    #[test]
2189    fn sqs_create_queue_synthesizes_with_explicit_mappings() {
2190        let request =
2191            request("sqs", "CreateQueue", serde_json::json!({"QueueName": "Synthetic", "tags": {"Team": "CLI"}}));
2192        let (classification, synthesis, document) = synthesized_json(&request);
2193        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2194        assert_eq!(classification.candidates, ["AWS::SQS::Queue"]);
2195        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::SynthesizedCreate));
2196        assert_eq!(document["Resources"]["Resource"]["Properties"]["QueueName"], "Synthetic");
2197    }
2198
2199    #[test]
2200    fn sqs_delete_queue_identifies_type_without_synthesizing() {
2201        let schema_validator = SchemaValidator::default();
2202        let request =
2203            request("sqs", "DeleteQueue", serde_json::json!({"QueueUrl": "https://sqs.us-east-1.amazonaws.com/123/Q"}));
2204        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2205        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationDelete);
2206        assert_eq!(classification.candidates, ["AWS::SQS::Queue"]);
2207        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2208        assert!(synthesis.template.is_none());
2209    }
2210    #[test]
2211    fn canonical_service_name_matching_is_case_insensitive() {
2212        let schema_validator = SchemaValidator::default();
2213        // Non-lowercase spellings of the canonical botocore service name must
2214        // normalize to the same adapter; service_name is ASCII case-normalized only.
2215        for (service, operation, expected_type) in [
2216            ("S3", "CreateBucket", "AWS::S3::Bucket"),
2217            ("DynamoDb", "CreateTable", "AWS::DynamoDB::Table"),
2218            ("Iam", "CreateRole", "AWS::IAM::Role"),
2219            ("Lambda", "CreateFunction", "AWS::Lambda::Function"),
2220            ("Sns", "CreateTopic", "AWS::SNS::Topic"),
2221            ("Sqs", "CreateQueue", "AWS::SQS::Queue"),
2222        ] {
2223            let req = request(service, operation, serde_json::json!({}));
2224            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2225            assert_eq!(
2226                classification.candidates,
2227                [expected_type],
2228                "canonical service name {service}:{operation} must resolve case-insensitively to {expected_type}"
2229            );
2230        }
2231    }
2232    #[test]
2233    fn template_body_is_accepted_only_for_cloudformation_operations() {
2234        let schema_validator = SchemaValidator::default();
2235        let template = br#"{"Resources":{}}"#.to_vec();
2236
2237        let mut cfn_request = request("cloudformation", "CreateChangeSet", serde_json::json!({}));
2238        cfn_request.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2239        let classification = classify_operation(&cfn_request, &schema_validator).expect("classification succeeds");
2240        let synthesis =
2241            synthesize_request(&cfn_request, &classification, &schema_validator).expect("synthesis succeeds");
2242        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::TemplateBody));
2243        assert_eq!(synthesis.template, Some(template.clone()));
2244
2245        let mut s3_request = request("s3", "PutObject", serde_json::json!({}));
2246        s3_request.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2247        let classification = classify_operation(&s3_request, &schema_validator).expect("classification succeeds");
2248        let synthesis =
2249            synthesize_request(&s3_request, &classification, &schema_validator).expect("synthesis succeeds");
2250        assert_ne!(synthesis.source, Some(AwsCliTemplateSource::TemplateBody));
2251    }
2252
2253    #[test]
2254    fn template_url_skip_only_for_cloudformation_operations() {
2255        let schema_validator = SchemaValidator::default();
2256        let cfn_request = request(
2257            "cloudformation",
2258            "CreateStack",
2259            serde_json::json!({"TemplateURL": "https://example.com/template.json"}),
2260        );
2261        let classification = classify_operation(&cfn_request, &schema_validator).expect("classification succeeds");
2262        let synthesis =
2263            synthesize_request(&cfn_request, &classification, &schema_validator).expect("synthesis succeeds");
2264        assert!(synthesis.template.is_none());
2265        assert!(synthesis.reason.contains("unavailable"));
2266    }
2267
2268    #[test]
2269    fn all_closed_template_body_operations_are_accepted() {
2270        let schema_validator = SchemaValidator::default();
2271        let template = br#"{"Resources":{}}"#.to_vec();
2272        for (service, operation) in TEMPLATE_BODY_OPERATIONS {
2273            let mut req = request(service, operation, serde_json::json!({}));
2274            req.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2275            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2276            let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2277            assert_eq!(
2278                synthesis.source,
2279                Some(AwsCliTemplateSource::TemplateBody),
2280                "{service}:{operation} should accept TemplateBody"
2281            );
2282        }
2283    }
2284    #[test]
2285    fn cloud_control_create_resource_wraps_desired_state() {
2286        let known = request(
2287            "cloudcontrol",
2288            "CreateResource",
2289            serde_json::json!({"TypeName": "AWS::SNS::Topic", "DesiredState": "{\"TopicName\":\"Synthetic\"}"}),
2290        );
2291        let (classification, synthesis, document) = synthesized_json(&known);
2292        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2293        assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2294        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2295        assert_eq!(document["Resources"]["Resource"]["Properties"]["TopicName"], "Synthetic");
2296    }
2297
2298    #[test]
2299    fn cloud_control_with_signing_prefix_cloudcontrolapi() {
2300        let parameters: HashMap<String, AwsCliValue> = serde_json::json!({
2301            "TypeName": "AWS::SNS::Topic",
2302            "DesiredState": "{\"TopicName\":\"Synthetic\"}"
2303        })
2304        .as_object()
2305        .unwrap()
2306        .iter()
2307        .map(|(k, v)| (k.clone(), AwsCliValue::from_json(v.clone())))
2308        .collect();
2309        let req =
2310            AwsCliCommand::new("cloudcontrol", "CreateResource", parameters).with_service_prefix("cloudcontrolapi");
2311        let (classification, synthesis, document) = synthesized_json(&req);
2312        assert_eq!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2313        assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2314        assert_eq!(synthesis.source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2315        assert_eq!(document["Resources"]["Resource"]["Properties"]["TopicName"], "Synthetic");
2316    }
2317
2318    #[test]
2319    fn cloud_control_rejects_unknown_type_name() {
2320        let schema_validator = SchemaValidator::default();
2321        let unknown = request(
2322            "cloudcontrol",
2323            "CreateResource",
2324            serde_json::json!({"TypeName": "AWS::Unknown::Type", "DesiredState": "{}"}),
2325        );
2326        let classification = classify_operation(&unknown, &schema_validator).expect("classification succeeds");
2327        let synthesis = synthesize_request(&unknown, &classification, &schema_validator).expect("synthesis succeeds");
2328        assert!(synthesis.template.is_none());
2329        assert!(synthesis.reason.contains("known CloudFormation TypeName"));
2330    }
2331
2332    #[test]
2333    fn cloud_control_update_resource_reports_type_but_does_not_synthesize() {
2334        let schema_validator = SchemaValidator::default();
2335        let update = request(
2336            "cloudcontrol",
2337            "UpdateResource",
2338            serde_json::json!({"TypeName": "AWS::SNS::Topic", "PatchDocument": "[{\"op\":\"replace\"}]"}),
2339        );
2340        let classification = classify_operation(&update, &schema_validator).expect("classification succeeds");
2341        assert_eq!(classification.kind, AwsCliOperationKind::UnmappedMutation);
2342        assert_eq!(classification.candidates, ["AWS::SNS::Topic"]);
2343        let synthesis = synthesize_request(&update, &classification, &schema_validator).expect("synthesis succeeds");
2344        assert!(synthesis.template.is_none());
2345        assert!(synthesis.reason.contains("PatchDocument"));
2346        assert_eq!(synthesis.resource_types, ["AWS::SNS::Topic"]);
2347    }
2348    #[test]
2349    fn explicit_readonly_and_http_get_are_authoritative_read_signals() {
2350        let schema_validator = SchemaValidator::default();
2351        let mut explicitly_readonly = request("test", "CreateThing", serde_json::json!({}));
2352        explicitly_readonly.is_read_only = Some(true);
2353        assert_eq!(
2354            classify_operation(&explicitly_readonly, &schema_validator).expect("classification succeeds").kind,
2355            AwsCliOperationKind::ReadOnly
2356        );
2357        let mut get_request = request("test", "FrobnicateThing", serde_json::json!({}));
2358        get_request.http_method = Some("GET".into());
2359        assert_eq!(
2360            classify_operation(&get_request, &schema_validator).expect("classification succeeds").kind,
2361            AwsCliOperationKind::ReadOnly
2362        );
2363    }
2364
2365    #[test]
2366    fn data_plane_verbs_are_classified_correctly() {
2367        let schema_validator = SchemaValidator::default();
2368        let lambda_invoke = request("lambda", "Invoke", serde_json::json!({}));
2369        assert_eq!(
2370            classify_operation(&lambda_invoke, &schema_validator).expect("classification succeeds").kind,
2371            AwsCliOperationKind::DataPlaneMutation
2372        );
2373    }
2374    #[test]
2375    fn ecs_run_task_never_maps_to_resource() {
2376        let schema_validator = SchemaValidator::default();
2377        let req = request("ecs", "RunTask", serde_json::json!({"TaskDefinition": "my-task"}));
2378        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2379        assert!(classification.candidates.is_empty(), "ecs:RunTask must not map to any resource type");
2380        assert_ne!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2381        let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2382        assert!(synthesis.template.is_none());
2383    }
2384
2385    #[test]
2386    fn ec2_start_instances_never_maps_to_resource() {
2387        let schema_validator = SchemaValidator::default();
2388        let req = request("ec2", "StartInstances", serde_json::json!({"InstanceIds": ["i-12345"]}));
2389        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2390        assert!(classification.candidates.is_empty(), "ec2:StartInstances must not map to any resource type");
2391        assert_ne!(classification.kind, AwsCliOperationKind::CloudFormationCreate);
2392        let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2393        assert!(synthesis.template.is_none());
2394    }
2395
2396    #[test]
2397    fn iot_start_thing_registration_task_never_maps_to_resource() {
2398        let schema_validator = SchemaValidator::default();
2399        let req = request("iot", "StartThingRegistrationTask", serde_json::json!({"TemplateBody": "{}"}));
2400        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2401        assert!(
2402            classification.candidates.is_empty(),
2403            "iot:StartThingRegistrationTask must not map to any resource type"
2404        );
2405        let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2406        assert_ne!(synthesis.source, Some(AwsCliTemplateSource::TemplateBody));
2407    }
2408
2409    #[test]
2410    fn wrong_service_template_body_is_not_treated_as_cfn_template() {
2411        let schema_validator = SchemaValidator::default();
2412        let template = br#"{"Resources":{}}"#.to_vec();
2413        for service in ["s3", "lambda", "iot", "dynamodb"] {
2414            let mut req = request(service, "SomeOperation", serde_json::json!({}));
2415            req.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: template.clone() });
2416            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2417            let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2418            assert_ne!(
2419                synthesis.source,
2420                Some(AwsCliTemplateSource::TemplateBody),
2421                "{service}:SomeOperation should not treat TemplateBody as CFN template"
2422            );
2423        }
2424    }
2425
2426    #[test]
2427    fn wrong_service_type_name_desired_state_is_not_wrapped() {
2428        let schema_validator = SchemaValidator::default();
2429        let req = request(
2430            "s3",
2431            "CreateResource",
2432            serde_json::json!({"TypeName": "AWS::SNS::Topic", "DesiredState": "{\"TopicName\":\"Synthetic\"}"}),
2433        );
2434        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2435        let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2436        assert_ne!(synthesis.source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2437    }
2438
2439    #[test]
2440    fn near_match_operation_names_never_map_or_synthesize() {
2441        let schema_validator = SchemaValidator::default();
2442        for (service, operation) in [
2443            ("s3", "CreateBuckets"),
2444            ("s3", "createBucket"),
2445            ("dynamodb", "CreateTables"),
2446            ("lambda", "CreateFunctions"),
2447            ("lambda", "UpdateFunctionConfigurations"),
2448        ] {
2449            let req = request(service, operation, serde_json::json!({}));
2450            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2451            assert!(
2452                classification.candidates.is_empty(),
2453                "{service}:{operation} near-match must not resolve to any adapter"
2454            );
2455        }
2456    }
2457    #[test]
2458    fn incompatible_property_value_skips_synthesis() {
2459        let schema_validator = SchemaValidator::default();
2460        // MaxSessionDuration is mapped and expects integer; pass a boolean.
2461        let request = request(
2462            "iam",
2463            "CreateRole",
2464            serde_json::json!({
2465                "RoleName": "Synthetic",
2466                "AssumeRolePolicyDocument": "{}",
2467                "MaxSessionDuration": true
2468            }),
2469        );
2470        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2471        let synthesis = synthesize_request(&request, &classification, &schema_validator).expect("synthesis succeeds");
2472        assert!(synthesis.template.is_none(), "incompatible value must skip synthesis");
2473        assert!(
2474            synthesis.reason.contains("cannot be represented"),
2475            "reason must explain the type mismatch: {}",
2476            synthesis.reason
2477        );
2478    }
2479
2480    #[test]
2481    fn high_level_api_validates_exact_template_and_reports_skips() {
2482        let engine = NoopEngine::default();
2483        let schema_validator = SchemaValidator::default();
2484        let mut exact = request("cloudformation", "CreateChangeSet", serde_json::json!({}));
2485        exact.parameters.insert("TemplateBody".into(), AwsCliValue::Bytes { value: br#"{"Resources":{}}"#.to_vec() });
2486        let validation = validate_aws_cli_command(&engine, &schema_validator, &exact).expect("validation succeeds");
2487        assert_eq!(validation.status, AwsCliCommandValidationStatus::Validated);
2488        assert_eq!(validation.template_source, Some(AwsCliTemplateSource::TemplateBody));
2489        assert!(validation.report.is_some());
2490        assert_eq!(
2491            validation.template,
2492            Some(br#"{"Resources":{}}"#.to_vec()),
2493            "exact TemplateBody bytes must be preserved without reserializing"
2494        );
2495
2496        let read = request("iam", "GetRole", serde_json::json!({"RoleName": "Synthetic"}));
2497        let validation = validate_aws_cli_command(&engine, &schema_validator, &read).expect("classification succeeds");
2498        assert_eq!(validation.status, AwsCliCommandValidationStatus::Skipped);
2499        assert_eq!(validation.operation_kind, AwsCliOperationKind::ReadOnly);
2500        assert!(validation.report.is_none());
2501        assert_eq!(validation.template, None, "skipped requests must have template=None");
2502    }
2503
2504    /// Emits one template-authoring advice diagnostic and one value-validity
2505    /// diagnostic on `Properties.QueueName` of every resource.
2506    struct AdviceEngine(NoopEngine);
2507
2508    impl ValidationEngine for AdviceEngine {
2509        fn engine_name(&self) -> &str {
2510            "advice"
2511        }
2512
2513        fn evaluate_rules(
2514            &self,
2515            model: &Arc<SemanticModel>,
2516            _config: &ValidateConfig,
2517        ) -> Result<Vec<Diagnostic>, ValidationError> {
2518            Ok(model
2519                .resources
2520                .keys()
2521                .flat_map(|logical_id| {
2522                    ["W9002", "W3030"].into_iter().map(move |rule_id| {
2523                        RegisteredDiagnostic::new(rule_id, format!("{rule_id} on {logical_id}"))
2524                            .resource(logical_id.clone(), None)
2525                            .property_path("Properties.QueueName")
2526                            .build()
2527                    })
2528                })
2529                .collect())
2530        }
2531
2532        fn list_rules(&self) -> Vec<RuleInfo> {
2533            self.0.list_rules()
2534        }
2535
2536        fn rule_metadata(&self) -> &HashMap<String, RuleMetadataEntry> {
2537            self.0.rule_metadata()
2538        }
2539
2540        fn external_rule_metadata(&self) -> HashMap<String, RuleMetadataEntry> {
2541            self.0.external_rule_metadata()
2542        }
2543
2544        fn init_metric(&self) -> &PhaseMetric {
2545            self.0.init_metric()
2546        }
2547    }
2548
2549    fn reported_rule_ids(validation: &AwsCliCommandValidation) -> BTreeSet<String> {
2550        let report = validation.report.as_ref().expect("request must be validated");
2551        report.diagnostics.iter().map(|diagnostic| diagnostic.rule_id.clone()).collect()
2552    }
2553
2554    #[test]
2555    fn template_authoring_advice_is_dropped_for_modeled_state_but_kept_for_template_bodies() {
2556        let engine = AdviceEngine(NoopEngine::default());
2557        let schema_validator = SchemaValidator::default();
2558
2559        let mut template_body = request("cloudformation", "CreateStack", serde_json::json!({"StackName": "s"}));
2560        template_body.parameters.insert(
2561            "TemplateBody".into(),
2562            AwsCliValue::String {
2563                value: r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"orders"}}}}"#.into(),
2564            },
2565        );
2566        let validation =
2567            validate_aws_cli_command(&engine, &schema_validator, &template_body).expect("validation succeeds");
2568        assert_eq!(validation.template_source, Some(AwsCliTemplateSource::TemplateBody));
2569        let reported = reported_rule_ids(&validation);
2570        assert!(reported.contains("W9002"), "a real template keeps authoring advice: {reported:?}");
2571        assert!(reported.contains("W3030"), "{reported:?}");
2572
2573        let synthesized = request("sqs", "CreateQueue", serde_json::json!({"QueueName": "orders"}));
2574        let validation =
2575            validate_aws_cli_command(&engine, &schema_validator, &synthesized).expect("validation succeeds");
2576        assert_eq!(validation.template_source, Some(AwsCliTemplateSource::SynthesizedCreate));
2577        let reported = reported_rule_ids(&validation);
2578        assert!(!reported.contains("W9002"), "modeled API state drops authoring advice: {reported:?}");
2579        assert!(reported.contains("W3030"), "value findings are kept: {reported:?}");
2580        let report = validation.report.as_ref().expect("validated");
2581        assert!(report.metadata.suppressed >= 1, "dropped advice is counted as suppressed");
2582        assert_eq!(report.metadata.counts.warnings, 1);
2583
2584        let desired_state = request(
2585            "cloudcontrol",
2586            "CreateResource",
2587            serde_json::json!({"TypeName": "AWS::SQS::Queue", "DesiredState": "{\"QueueName\":\"orders\"}"}),
2588        );
2589        let validation =
2590            validate_aws_cli_command(&engine, &schema_validator, &desired_state).expect("validation succeeds");
2591        assert_eq!(validation.template_source, Some(AwsCliTemplateSource::CloudControlDesiredState));
2592        let reported = reported_rule_ids(&validation);
2593        assert!(!reported.contains("W9002"), "Cloud Control desired state drops authoring advice: {reported:?}");
2594        assert!(reported.contains("W3030"), "{reported:?}");
2595    }
2596
2597    #[test]
2598    fn partial_update_scoping_keeps_report_counts_consistent() {
2599        let engine = NoopEngine::default();
2600        let schema_validator = SchemaValidator::default();
2601        let update = request(
2602            "lambda",
2603            "UpdateFunctionConfiguration",
2604            serde_json::json!({"FunctionName": "Synthetic", "MemorySize": 0}),
2605        );
2606        let validation = validate_aws_cli_command(&engine, &schema_validator, &update).expect("validation succeeds");
2607        let report = validation.report.expect("update is validated");
2608        assert!(
2609            report
2610                .diagnostics
2611                .iter()
2612                .all(|diagnostic| diagnostic.property_path.as_deref().is_some_and(|path| path.contains("MemorySize")))
2613        );
2614        let counts = &report.metadata.counts;
2615        assert_eq!(
2616            report.diagnostics.len() as u32,
2617            counts.fatal + counts.errors + counts.warnings + counts.informational + counts.debug
2618        );
2619    }
2620
2621    #[test]
2622    fn request_parameters_are_not_mutated() {
2623        let parameters = HashMap::from([
2624            ("TableName".into(), value(serde_json::json!("Synthetic"))),
2625            ("KeySchema".into(), value(serde_json::json!([{"AttributeName": "id", "KeyType": "HASH"}]))),
2626            ("AttributeDefinitions".into(), value(serde_json::json!([{"AttributeName": "id", "AttributeType": "S"}]))),
2627        ]);
2628        let original = parameters.clone();
2629        let request = AwsCliCommand::new("dynamodb", "CreateTable", parameters);
2630        let schema_validator = SchemaValidator::default();
2631        let classification = classify_operation(&request, &schema_validator).expect("classification succeeds");
2632        let _ = synthesize_request(&request, &classification, &schema_validator);
2633        assert_eq!(request.parameters, original);
2634    }
2635
2636    #[test]
2637    fn json_conversion_rejects_non_json_values_without_coercion() {
2638        assert!(AwsCliValue::Bytes { value: vec![1, 2] }.to_json().is_err());
2639        assert!(AwsCliValue::Number { value: f64::NAN }.to_json().is_err());
2640        assert!(AwsCliValue::Unsupported { type_name: "timestamp".into() }.to_json().is_err());
2641    }
2642
2643    #[test]
2644    fn operation_words_preserve_acronyms_for_verb_classification() {
2645        assert_eq!(operation_words("BatchCreateDB2Cluster"), ["Batch", "Create", "DB", "2", "Cluster"]);
2646        assert_eq!(effective_verb(&operation_words("BatchCreateDB2Cluster")), "Create");
2647    }
2648
2649    #[test]
2650    fn normalize_service_changes_ascii_case_only() {
2651        assert_eq!(normalize_service("s3"), "s3");
2652        assert_eq!(normalize_service("S3"), "s3");
2653        assert_eq!(normalize_service("DynamoDb"), "dynamodb");
2654        assert_eq!(normalize_service("dynamodb"), "dynamodb");
2655        assert_eq!(normalize_service("cloud-control"), "cloud-control");
2656        assert_eq!(normalize_service("CloudControl"), "cloudcontrol");
2657        assert_eq!(normalize_service("cloudcontrolapi"), "cloudcontrolapi");
2658    }
2659
2660    #[test]
2661    fn conflicting_service_prefix_does_not_map_adapter() {
2662        let schema_validator = SchemaValidator::default();
2663        let parameters: HashMap<String, AwsCliValue> = serde_json::json!({"Bucket": "test"})
2664            .as_object()
2665            .unwrap()
2666            .iter()
2667            .map(|(k, v)| (k.clone(), AwsCliValue::from_json(v.clone())))
2668            .collect();
2669        let req = AwsCliCommand::new("ecs", "CreateBucket", parameters).with_service_prefix("s3");
2670        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2671        assert!(
2672            classification.candidates.is_empty(),
2673            "service_name=ecs with service_prefix=s3 must not map CreateBucket"
2674        );
2675
2676        let punctuated = request("s-3", "CreateBucket", serde_json::json!({"Bucket": "test"}));
2677        let classification = classify_operation(&punctuated, &schema_validator).expect("classification succeeds");
2678        assert!(classification.candidates.is_empty(), "punctuated service names must not map adapters");
2679    }
2680
2681    #[test]
2682    fn conflicting_service_prefix_does_not_validate_template_body() {
2683        let schema_validator = SchemaValidator::default();
2684        let template = br#"{"Resources":{}}"#.to_vec();
2685        let parameters: HashMap<String, AwsCliValue> =
2686            [("TemplateBody".to_string(), AwsCliValue::Bytes { value: template })].into_iter().collect();
2687        let req = AwsCliCommand::new("iot", "CreateStack", parameters).with_service_prefix("cloudformation");
2688        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2689        let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2690        assert_ne!(
2691            synthesis.source,
2692            Some(AwsCliTemplateSource::TemplateBody),
2693            "service_name=iot with service_prefix=cloudformation must not validate TemplateBody"
2694        );
2695    }
2696
2697    #[test]
2698    fn arbitrary_cloudcontrol_operation_with_valid_type_name_has_no_candidates() {
2699        let schema_validator = SchemaValidator::default();
2700        let req = request("cloudcontrol", "ListResources", serde_json::json!({"TypeName": "AWS::SNS::Topic"}));
2701        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2702        assert!(
2703            classification.candidates.is_empty(),
2704            "arbitrary cloudcontrol operations must not produce resource_types even with valid TypeName"
2705        );
2706    }
2707
2708    #[test]
2709    fn lambda_create_partial_scopes_to_mapped_properties_only() {
2710        let engine = NoopEngine::default();
2711        let schema_validator = SchemaValidator::default();
2712        let req = request("lambda", "CreateFunction", serde_json::json!({"MemorySize": 0}));
2713        let validation = validate_aws_cli_command(&engine, &schema_validator, &req).expect("validation succeeds");
2714        assert_eq!(validation.status, AwsCliCommandValidationStatus::Validated);
2715        assert_eq!(validation.template_source, Some(AwsCliTemplateSource::SynthesizedCreate));
2716        let report = validation.report.expect("create is validated");
2717        for diagnostic in &report.diagnostics {
2718            assert!(
2719                diagnostic.property_path.as_deref().is_some_and(|p| p.contains("MemorySize")),
2720                "diagnostic must be scoped to MemorySize, got: {:?}",
2721                diagnostic.property_path
2722            );
2723        }
2724        let counts = &report.metadata.counts;
2725        assert_eq!(
2726            report.diagnostics.len() as u32,
2727            counts.fatal + counts.errors + counts.warnings + counts.informational + counts.debug,
2728        );
2729        // The template field carries the synthesized JSON used for validation.
2730        let template_bytes = validation.template.expect("validated requests carry template bytes");
2731        let template_json: serde_json::Value =
2732            serde_json::from_slice(&template_bytes).expect("template must be valid JSON");
2733        assert_eq!(template_json["Resources"]["Resource"]["Type"], "AWS::Lambda::Function");
2734        assert_eq!(template_json["Resources"]["Resource"]["Properties"]["MemorySize"], 0);
2735    }
2736
2737    #[test]
2738    fn template_body_create_operations_have_cloud_formation_create_kind() {
2739        let schema_validator = SchemaValidator::default();
2740        for operation in ["CreateChangeSet", "CreateStack", "CreateStackSet"] {
2741            let req = request("cloudformation", operation, serde_json::json!({}));
2742            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2743            assert_eq!(
2744                classification.kind,
2745                AwsCliOperationKind::CloudFormationCreate,
2746                "cloudformation:{operation} must be CloudFormationCreate"
2747            );
2748        }
2749    }
2750
2751    #[test]
2752    fn template_body_update_operations_have_cloud_formation_update_kind() {
2753        let schema_validator = SchemaValidator::default();
2754        for operation in ["UpdateStack", "UpdateStackSet"] {
2755            let req = request("cloudformation", operation, serde_json::json!({}));
2756            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2757            assert_eq!(
2758                classification.kind,
2759                AwsCliOperationKind::CloudFormationUpdate,
2760                "cloudformation:{operation} must be CloudFormationUpdate"
2761            );
2762        }
2763    }
2764
2765    #[test]
2766    fn template_body_readonly_operations_have_readonly_kind() {
2767        let schema_validator = SchemaValidator::default();
2768        for operation in ["EstimateTemplateCost", "GetTemplateSummary", "ValidateTemplate"] {
2769            let req = request("cloudformation", operation, serde_json::json!({}));
2770            let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2771            assert_eq!(
2772                classification.kind,
2773                AwsCliOperationKind::ReadOnly,
2774                "cloudformation:{operation} must be ReadOnly"
2775            );
2776        }
2777    }
2778
2779    #[test]
2780    fn template_body_readonly_operations_still_validate_payload() {
2781        let engine = NoopEngine::default();
2782        let schema_validator = SchemaValidator::default();
2783        let template = br#"{"Resources":{}}"#.to_vec();
2784        for operation in ["EstimateTemplateCost", "GetTemplateSummary", "ValidateTemplate"] {
2785            let parameters: HashMap<String, AwsCliValue> =
2786                [("TemplateBody".to_string(), AwsCliValue::Bytes { value: template.clone() })].into_iter().collect();
2787            let req = AwsCliCommand::new("cloudformation", operation, parameters);
2788            let validation = validate_aws_cli_command(&engine, &schema_validator, &req).expect("validation succeeds");
2789            assert_eq!(
2790                validation.status,
2791                AwsCliCommandValidationStatus::Validated,
2792                "cloudformation:{operation} with TemplateBody must still validate"
2793            );
2794            assert_eq!(validation.template_source, Some(AwsCliTemplateSource::TemplateBody));
2795        }
2796    }
2797
2798    #[test]
2799    fn unknown_cloudformation_verbs_remain_unmapped() {
2800        let schema_validator = SchemaValidator::default();
2801        let req = request("cloudformation", "DeleteChangeSet", serde_json::json!({}));
2802        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2803        assert_eq!(classification.kind, AwsCliOperationKind::UnmappedMutation);
2804        assert!(classification.candidates.is_empty());
2805    }
2806
2807    #[test]
2808    fn unmapped_parameter_skips_synthesis_with_reason() {
2809        let schema_validator = SchemaValidator::default();
2810        let req = request("s3", "CreateBucket", serde_json::json!({"Bucket": "test-bucket", "UnknownParam": "value"}));
2811        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2812        let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2813        assert!(synthesis.template.is_none(), "unmapped parameter must skip synthesis");
2814        assert!(
2815            synthesis.reason.contains("UnknownParam") && synthesis.reason.contains("no mapping"),
2816            "reason must name the unmapped parameter: {}",
2817            synthesis.reason
2818        );
2819    }
2820
2821    #[test]
2822    fn ignored_input_does_not_block_synthesis() {
2823        let schema_validator = SchemaValidator::default();
2824        let schema = schema_validator.resource_schema_metadata("AWS::S3::Bucket").expect("S3 bucket schema must exist");
2825        let adapter = OperationAdapter {
2826            service: "s3".into(),
2827            operation: "CreateBucket".into(),
2828            phase: AdapterPhase::Create,
2829            cfn_type: "AWS::S3::Bucket".into(),
2830            mappings: vec![mapping("Bucket", "BucketName")],
2831            ignored_inputs: vec!["ClientToken".into()],
2832        };
2833        let parameters: HashMap<String, AwsCliValue> = [
2834            ("Bucket".into(), AwsCliValue::String { value: "test".into() }),
2835            ("ClientToken".into(), AwsCliValue::String { value: "idempotent-token".into() }),
2836        ]
2837        .into_iter()
2838        .collect();
2839        let result = map_adapter_properties(&parameters, &schema, &adapter).expect("mapping must succeed");
2840        match result {
2841            AdapterMappingResult::Mapped(properties) => {
2842                assert_eq!(properties.len(), 1);
2843                assert_eq!(properties["BucketName"], serde_json::json!("test"));
2844            }
2845            AdapterMappingResult::Skip(reason) => {
2846                panic!("ignored input should not skip synthesis: {reason}");
2847            }
2848        }
2849    }
2850
2851    #[test]
2852    fn update_adapter_ignores_primary_identifier_parameters() {
2853        let schema_validator = SchemaValidator::default();
2854        let schema = schema_validator
2855            .resource_schema_metadata("AWS::Lambda::Function")
2856            .expect("Lambda function schema must exist");
2857        let adapter = OperationAdapter {
2858            service: "lambda".into(),
2859            operation: "UpdateFunctionConfiguration".into(),
2860            phase: AdapterPhase::Update,
2861            cfn_type: "AWS::Lambda::Function".into(),
2862            mappings: vec![mapping("MemorySize", "MemorySize")],
2863            ignored_inputs: Vec::new(),
2864        };
2865        // FunctionName is a primary identifier for AWS::Lambda::Function
2866        let parameters: HashMap<String, AwsCliValue> = [
2867            ("MemorySize".into(), AwsCliValue::Integer { value: 256 }),
2868            ("FunctionName".into(), AwsCliValue::String { value: "my-func".into() }),
2869        ]
2870        .into_iter()
2871        .collect();
2872        let result = map_adapter_properties(&parameters, &schema, &adapter).expect("mapping must succeed");
2873        match result {
2874            AdapterMappingResult::Mapped(properties) => {
2875                assert_eq!(properties.len(), 1);
2876                assert_eq!(properties["MemorySize"], serde_json::json!(256));
2877            }
2878            AdapterMappingResult::Skip(reason) => {
2879                panic!("primary identifier on update must be ignored: {reason}");
2880            }
2881        }
2882    }
2883
2884    #[test]
2885    fn all_mapped_parameters_produce_successful_synthesis() {
2886        let schema_validator = SchemaValidator::default();
2887        let req = request("s3", "CreateBucket", serde_json::json!({"Bucket": "all-mapped"}));
2888        let classification = classify_operation(&req, &schema_validator).expect("classification succeeds");
2889        let synthesis = synthesize_request(&req, &classification, &schema_validator).expect("synthesis succeeds");
2890        assert!(synthesis.template.is_some(), "all-mapped parameters must synthesize");
2891    }
2892
2893    #[test]
2894    fn catalog_ignored_inputs_deserialize_from_missing_field() {
2895        let json = br#"{
2896            "format_version": 1,
2897            "adapters": [
2898                {"service":"test","operation":"Create","phase":"create","cfn_type":"AWS::Test::Type","mappings":[]}
2899            ]
2900        }"#;
2901        let registry = parse_adapter_registry(json).expect("catalog without ignored_inputs must parse");
2902        let adapter = registry.get(&("test".to_string(), "Create".to_string())).expect("adapter must exist");
2903        assert!(adapter.ignored_inputs.is_empty(), "missing field defaults to empty");
2904    }
2905
2906    #[test]
2907    fn catalog_ignored_inputs_deserialize_from_explicit_field() {
2908        let json = br#"{
2909            "format_version": 1,
2910            "adapters": [
2911                {"service":"test","operation":"Create","phase":"create","cfn_type":"AWS::Test::Type",
2912                 "mappings":[],"ignored_inputs":["ClientToken","DryRun"]}
2913            ]
2914        }"#;
2915        let registry = parse_adapter_registry(json).expect("catalog with ignored_inputs must parse");
2916        let adapter = registry.get(&("test".to_string(), "Create".to_string())).expect("adapter must exist");
2917        assert_eq!(adapter.ignored_inputs, vec!["ClientToken", "DryRun"]);
2918    }
2919}