use crate::message::{quote, render_str_list};
use crate::model::SemanticModel;
use crate::regions::AWS_REGIONS;
use crate::resolved_value::resolved_value_at_path;
use crate::resolver::ResolvedValue;
use std::collections::{BTreeSet, HashSet};
pub const REGION_SCOPED_TYPE_PATHS: &[(&str, &str)] = &[
("AWS::EC2::Instance", "Properties.InstanceType"),
("AWS::GameLift::Fleet", "Properties.EC2InstanceType"),
("AWS::EMR::InstanceTypeConfig", "Properties.InstanceType"),
("AWS::EMR::InstanceFleetConfig", "Properties.InstanceType"),
("AWS::ManagedBlockchain::Node", "Properties.NodeConfiguration.InstanceType"),
("AWS::DocDB::DBInstance", "Properties.DBInstanceClass"),
("AWS::AppStream::Fleet", "Properties.InstanceType"),
("AWS::ElastiCache::CacheCluster", "Properties.CacheNodeType"),
("AWS::DAX::Cluster", "Properties.NodeType"),
("AWS::Neptune::DBInstance", "Properties.DBInstanceClass"),
("AWS::Redshift::Cluster", "Properties.NodeType"),
("AWS::AmazonMQ::Broker", "Properties.HostInstanceType"),
("AWS::RDS::DBInstance", "Properties.DBInstanceClass"),
("AWS::RDS::DBCluster", "Properties.DBClusterInstanceClass"),
("AWS::SageMaker::DataQualityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
("AWS::SageMaker::ModelBiasJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
("AWS::SageMaker::ModelExplainabilityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
("AWS::SageMaker::ModelQualityJobDefinition", "Properties.JobResources.ClusterConfig.InstanceType"),
(
"AWS::SageMaker::MonitoringSchedule",
"Properties.MonitoringScheduleConfig.MonitoringJobDefinition.MonitoringResources.ClusterConfig.InstanceType",
),
("AWS::Elasticsearch::Domain", "Properties.ElasticsearchClusterConfig.InstanceType"),
("AWS::OpenSearchService::Domain", "Properties.ClusterConfig.InstanceType"),
(
"AWS::SageMaker::InferenceExperiment",
"Properties.ModelVariants.{}.InfrastructureConfig.RealTimeInferenceConfig.InstanceType",
),
(
"AWS::SageMaker::ModelPackage",
"Properties.ValidationSpecification.ValidationProfiles.{}.TransformJobDefinition.TransformResources.InstanceType",
),
("AWS::SageMaker::Cluster", "Properties.InstanceGroups.{}.InstanceType"),
("AWS::SageMaker::Cluster", "Properties.RestrictedInstanceGroups.{}.InstanceType"),
];
pub fn template_has_region_scoped_value(model: &SemanticModel) -> bool {
REGION_SCOPED_TYPE_PATHS.iter().any(|(rtype, path)| {
model.resources_of_type(rtype).iter().any(|rid| resolves_to_concrete_string(model, rid, path))
})
}
fn resolves_to_concrete_string(model: &SemanticModel, rid: &str, path: &str) -> bool {
let Some(resolved) = resolve_for_detection(model, rid, path) else {
return false;
};
any_concrete_string(&resolved)
}
fn resolve_for_detection(model: &SemanticModel, rid: &str, path: &str) -> Option<ResolvedValue> {
if path.contains("{}") {
let resource = model.resources.get(rid)?;
let prop_path = path.strip_prefix("Properties.").unwrap_or(path);
let mut segments = prop_path.splitn(2, '.');
let top_key = segments.next()?;
let resolved = resource.properties.get(top_key)?;
return match segments.next() {
Some(rest) if !rest.is_empty() => resolved_value_at_path(resolved, rest),
_ => Some(resolved.clone()),
};
}
model.resolve_deep(rid, path).or_else(|| model.resolve(rid, path).cloned())
}
fn any_concrete_string(value: &ResolvedValue) -> bool {
match value {
ResolvedValue::Concrete { value } => value.0.is_string(),
ResolvedValue::List { items } | ResolvedValue::Enum { variants: items } => {
items.iter().any(any_concrete_string)
}
ResolvedValue::Conditional { if_true, if_false, .. } => {
any_concrete_string(if_true) || any_concrete_string(if_false)
}
_ => false,
}
}
pub const ANY_REGION_LABEL: &str = "any region";
pub fn region_label(region: Option<&str>) -> &str {
region.unwrap_or(ANY_REGION_LABEL)
}
pub fn flat_invalid_message(value: &str, region: Option<&str>) -> String {
match region {
Some(region) => format!("'{value}' is not valid for region '{region}'"),
None => format!("'{value}' is not valid in any region"),
}
}
pub fn conditional_invalid_message(value: &str, allowed_sorted: &[String], region: Option<&str>) -> String {
let rendered = render_str_list(allowed_sorted);
match region {
Some(region) => format!("'{value}' is not one of {rendered} in '{region}'"),
None => format!("'{value}' is not one of {rendered} in any region"),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConditionalEnumMismatch {
allowed_values: Vec<String>,
property_constraints: Vec<(String, String)>,
}
#[derive(Clone, Debug)]
struct ConditionalBranchEnum {
allowed_values: HashSet<String>,
property_constraints: Vec<(String, String)>,
}
pub fn conditional_mismatch_message(value: &str, mismatch: &ConditionalEnumMismatch, region: Option<&str>) -> String {
if mismatch.property_constraints.is_empty() {
return conditional_invalid_message(value, &mismatch.allowed_values, region);
}
let rendered_allowed = render_str_list(&mismatch.allowed_values);
let rendered_constraints = mismatch
.property_constraints
.iter()
.map(|(property, expected)| format!("{property} is {}", quote(expected)))
.collect::<Vec<_>>()
.join(" and ");
match region {
Some(region) => format!("'{value}' is not one of {rendered_allowed} when {rendered_constraints} in '{region}'"),
None => format!("'{value}' is not one of {rendered_allowed} when {rendered_constraints} in any region"),
}
}
pub fn flat_allowed_value_set<'a>(
doc: &'a serde_json::Map<String, serde_json::Value>,
region: Option<&str>,
) -> Option<HashSet<&'a str>> {
match region {
Some(region) => {
let values = doc.get(region)?.get("enum")?.as_array()?;
Some(values.iter().filter_map(|v| v.as_str()).collect())
}
None => {
let mut union = HashSet::new();
for region in AWS_REGIONS {
if let Some(values) = doc.get(*region).and_then(|r| r.get("enum")).and_then(|e| e.as_array()) {
union.extend(values.iter().filter_map(|v| v.as_str()));
}
}
(!union.is_empty()).then_some(union)
}
}
}
pub fn flat_allowed_values<'a>(
doc: &'a serde_json::Map<String, serde_json::Value>,
region: Option<&str>,
) -> Option<BTreeSet<&'a str>> {
flat_allowed_value_set(doc, region).map(|allowed| allowed.into_iter().collect())
}
pub fn conditional_invalid_enum<F>(
doc: &serde_json::Map<String, serde_json::Value>,
region: Option<&str>,
target_prop: &str,
normalize_engine_case: bool,
value: &str,
resolve_prop: F,
) -> Option<Vec<String>>
where
F: Fn(&str) -> Option<String>,
{
conditional_enum_mismatch(doc, region, target_prop, normalize_engine_case, value, resolve_prop)
.map(|mismatch| mismatch.allowed_values)
}
pub fn conditional_enum_mismatch<F>(
doc: &serde_json::Map<String, serde_json::Value>,
region: Option<&str>,
target_prop: &str,
normalize_engine_case: bool,
value: &str,
resolve_prop: F,
) -> Option<ConditionalEnumMismatch>
where
F: Fn(&str) -> Option<String>,
{
match region {
Some(region) => {
let region_doc = doc.get(region)?;
let branch_enums = conditional_branch_enums(region_doc, target_prop, normalize_engine_case, &resolve_prop);
invalid_branch_mismatch(&branch_enums, value)
}
None => {
let mut valid_somewhere = false;
let mut had_matching_branch = false;
let mut failing_largest: Option<ConditionalEnumMismatch> = None;
for region in AWS_REGIONS {
let Some(region_doc) = doc.get(*region) else {
continue;
};
let branch_enums =
conditional_branch_enums(region_doc, target_prop, normalize_engine_case, &resolve_prop);
if branch_enums.is_empty() {
continue;
}
had_matching_branch = true;
if branch_enums.iter().all(|branch| branch.allowed_values.contains(value)) {
valid_somewhere = true;
break;
}
if let Some(mismatch) = invalid_branch_mismatch(&branch_enums, value)
&& failing_largest
.as_ref()
.is_none_or(|current| mismatch.allowed_values.len() > current.allowed_values.len())
{
failing_largest = Some(mismatch);
}
}
if !had_matching_branch || valid_somewhere {
return None;
}
failing_largest
}
}
}
fn conditional_branch_enums<F>(
region_doc: &serde_json::Value,
target_prop: &str,
normalize_engine_case: bool,
resolve_prop: &F,
) -> Vec<ConditionalBranchEnum>
where
F: Fn(&str) -> Option<String>,
{
let mut matching_branches = Vec::new();
let Some(branches) = region_doc.get("allOf").and_then(|value| value.as_array()) else {
return matching_branches;
};
for branch in branches {
let (Some(required), Some(if_properties)) = (
branch.get("if").and_then(|condition| condition.get("required")).and_then(|value| value.as_array()),
branch.get("if").and_then(|condition| condition.get("properties")).and_then(|value| value.as_object()),
) else {
continue;
};
let mut property_constraints = Vec::new();
let mut all_required_match = true;
for property in required.iter().filter_map(|value| value.as_str()).filter(|property| *property != target_prop) {
let Some(expected) =
if_properties.get(property).and_then(|schema| schema.get("const")).and_then(|value| value.as_str())
else {
all_required_match = false;
break;
};
let Some(actual) = resolve_prop(property) else {
all_required_match = false;
break;
};
let matches = if normalize_engine_case && property == "Engine" {
actual.eq_ignore_ascii_case(expected)
} else {
actual == expected
};
if !matches {
all_required_match = false;
break;
}
property_constraints.push((property.to_string(), expected.to_string()));
}
if !all_required_match {
continue;
}
property_constraints.sort_unstable();
let Some(enum_values) = branch
.get("then")
.and_then(|then_schema| then_schema.get("properties"))
.and_then(|properties| properties.get(target_prop))
.and_then(|property_schema| property_schema.get("enum"))
.and_then(|value| value.as_array())
else {
continue;
};
matching_branches.push(ConditionalBranchEnum {
allowed_values: enum_values.iter().filter_map(|value| value.as_str().map(String::from)).collect(),
property_constraints,
});
}
matching_branches
}
fn invalid_branch_mismatch(branch_enums: &[ConditionalBranchEnum], value: &str) -> Option<ConditionalEnumMismatch> {
let failing_largest = branch_enums
.iter()
.filter(|branch| !branch.allowed_values.contains(value))
.max_by_key(|branch| branch.allowed_values.len())?;
let mut allowed_values: Vec<String> = failing_largest.allowed_values.iter().cloned().collect();
allowed_values.sort();
Some(ConditionalEnumMismatch { allowed_values, property_constraints: failing_largest.property_constraints.clone() })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn flat_doc() -> serde_json::Map<String, serde_json::Value> {
json!({
"us-east-1": { "enum": ["common.type", "east.type"] },
"ap-east-2": { "enum": ["common.type", "special.type"] },
"all": { "enum": ["should.be.ignored"] },
"description": "generated"
})
.as_object()
.unwrap()
.clone()
}
#[test]
fn region_label_uses_region_or_any_region() {
assert_eq!(region_label(Some("us-west-2")), "us-west-2");
assert_eq!(region_label(None), "any region");
}
#[test]
fn flat_with_region_validates_that_region_only() {
let doc = flat_doc();
let allowed = flat_allowed_values(&doc, Some("us-east-1")).unwrap();
assert!(allowed.contains("east.type"));
assert!(!allowed.contains("special.type"), "special.type is not valid in us-east-1");
}
#[test]
fn flat_without_region_unions_all_regions() {
let doc = flat_doc();
let allowed = flat_allowed_values(&doc, None).unwrap();
assert!(allowed.contains("east.type"));
assert!(allowed.contains("special.type"), "special.type is valid in ap-east-2, so present in the union");
assert!(!allowed.contains("should.be.ignored"), "the synthetic 'all' key must not contribute");
}
#[test]
fn flat_unknown_configured_region_returns_none() {
let doc = flat_doc();
assert!(flat_allowed_values(&doc, Some("mars-north-1")).is_none());
}
#[test]
fn flat_without_region_returns_none_when_no_region_keys() {
let doc = json!({ "description": "x", "all": { "enum": ["a"] } }).as_object().unwrap().clone();
assert!(flat_allowed_values(&doc, None).is_none(), "no real region keys → nothing to validate");
}
fn conditional_doc() -> serde_json::Map<String, serde_json::Value> {
let branch = |class: &str| {
json!({
"if": { "required": ["Engine", "DBInstanceClass"], "properties": { "Engine": { "const": "mysql" } } },
"then": { "properties": { "DBInstanceClass": { "enum": [class] } } }
})
};
json!({
"us-east-1": { "allOf": [branch("db.small")] },
"ap-east-2": { "allOf": [ { "if": { "required": ["Engine", "DBInstanceClass"], "properties": { "Engine": { "const": "mysql" } } }, "then": { "properties": { "DBInstanceClass": { "enum": ["db.small", "db.big"] } } } } ] }
})
.as_object()
.unwrap()
.clone()
}
#[test]
fn conditional_with_region_flags_value_absent_from_that_region() {
let doc = conditional_doc();
let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
let invalid = conditional_invalid_enum(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.big", engine);
assert_eq!(invalid, Some(vec!["db.small".to_string()]));
}
#[test]
fn conditional_without_region_accepts_value_valid_in_any_region() {
let doc = conditional_doc();
let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
let invalid = conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.big", engine);
assert!(invalid.is_none(), "db.big is valid in ap-east-2 → not flagged when region is unset");
}
#[test]
fn conditional_without_region_flags_value_valid_nowhere() {
let doc = conditional_doc();
let engine = |p: &str| (p == "Engine").then(|| "mysql".to_string());
let invalid = conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.bogus", engine);
assert!(invalid.is_some(), "db.bogus is valid in no region → flagged");
let rendered = invalid.unwrap();
assert!(rendered.contains(&"db.big".to_string()), "renders the largest failing branch across regions");
}
#[test]
fn conditional_message_names_the_engine_that_selected_the_enum() {
let doc = conditional_doc();
let engine = |property: &str| (property == "Engine").then(|| "MySQL".to_string());
let mismatch = conditional_enum_mismatch(&doc, None, "DBInstanceClass", true, "db.bogus", engine).unwrap();
assert_eq!(
conditional_mismatch_message("db.bogus", &mismatch, None),
"'db.bogus' is not one of ['db.big', 'db.small'] when Engine is 'mysql' in any region"
);
}
#[test]
fn conditional_message_includes_every_constraint_on_the_failing_branch() {
let doc = json!({
"us-east-1": {
"allOf": [{
"if": {
"required": ["Engine", "LicenseModel", "DBInstanceClass"],
"properties": {
"Engine": { "const": "oracle-se2" },
"LicenseModel": { "const": "license-included" }
}
},
"then": { "properties": { "DBInstanceClass": { "enum": ["db.allowed"] } } }
}]
}
})
.as_object()
.unwrap()
.clone();
let properties = |property: &str| match property {
"Engine" => Some("oracle-se2".to_string()),
"LicenseModel" => Some("license-included".to_string()),
_ => None,
};
let mismatch =
conditional_enum_mismatch(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.bogus", properties)
.unwrap();
assert_eq!(
conditional_mismatch_message("db.bogus", &mismatch, Some("us-east-1")),
"'db.bogus' is not one of ['db.allowed'] when Engine is 'oracle-se2' and LicenseModel is 'license-included' in 'us-east-1'"
);
}
#[test]
fn conditional_unmatched_engine_is_not_validated() {
let doc = conditional_doc();
let engine = |p: &str| (p == "Engine").then(|| "oracle".to_string());
assert!(conditional_invalid_enum(&doc, None, "DBInstanceClass", true, "db.bogus", engine).is_none());
assert!(
conditional_invalid_enum(&doc, Some("us-east-1"), "DBInstanceClass", true, "db.bogus", engine).is_none()
);
}
}