Skip to main content

template_model/
message.rs

1use serde_json::Value;
2use std::collections::BTreeSet;
3
4/// Wraps `value` in single quotes: `foo` becomes `'foo'`.
5///
6/// This is the single convention for surfacing an identifier, property name, or
7/// literal inside a message. It never emits double quotes, so the rendered
8/// message survives JSON serialization without `\"` escaping.
9pub fn quote(value: impl AsRef<str>) -> String {
10    format!("'{}'", value.as_ref())
11}
12
13/// Renders a JSON value for display inside a message.
14///
15/// Strings are single-quoted; numbers, booleans, and null render as their bare
16/// literal. Arrays and objects recurse so their nested strings are single-quoted
17/// too, unlike `serde_json::to_string`, which would emit double quotes and force
18/// `\"` escaping in the final JSON report.
19pub fn render_value(value: &Value) -> String {
20    match value {
21        Value::String(s) => quote(s),
22        Value::Null => "null".to_string(),
23        Value::Bool(b) => b.to_string(),
24        Value::Number(n) => n.to_string(),
25        Value::Array(items) => render_value_list(items),
26        Value::Object(map) => {
27            let entries =
28                map.iter().map(|(k, v)| format!("{}: {}", quote(k), render_value(v))).collect::<Vec<_>>().join(", ");
29            format!("{{{}}}", entries)
30        }
31    }
32}
33
34/// Renders a slice of JSON values as a bracketed list, e.g. `['a', 'b']` or
35/// `[1, 2]`, with each element formatted by [`render_value`].
36pub fn render_value_list(values: &[Value]) -> String {
37    format!("[{}]", values.iter().map(render_value).collect::<Vec<_>>().join(", "))
38}
39
40/// Renders a sequence of string-like items as a single-quoted, bracketed list,
41/// e.g. `['Enabled', 'Suspended']`. Use this for enum candidates, valid
42/// attribute names, and other lists of plain strings.
43pub fn render_str_list<I, S>(items: I) -> String
44where
45    I: IntoIterator<Item = S>,
46    S: AsRef<str>,
47{
48    let rendered = items.into_iter().map(|item| quote(item.as_ref())).collect::<Vec<_>>().join(", ");
49    format!("[{}]", rendered)
50}
51
52const S3_BUCKET_POLICY_RESOURCE_TYPE: &str = "AWS::S3::BucketPolicy";
53const S3_BUCKET_IDENTIFIER_PROPERTY: &str = "Bucket";
54
55/// Describes resources whose authored primary identifiers resolve to the same
56/// physical resource. Bucket policies receive target-oriented wording because
57/// their identifier names the bucket they modify rather than a resource they create.
58pub fn primary_identifier_conflict_message(
59    resource_type: &str,
60    identifier_properties: &[String],
61    identifier_values: &[String],
62    resource_ids: &BTreeSet<String>,
63) -> String {
64    let rendered_resources = render_string_set(resource_ids);
65    if resource_type == S3_BUCKET_POLICY_RESOURCE_TYPE
66        && identifier_properties.first().is_some_and(|property| property == S3_BUCKET_IDENTIFIER_PROPERTY)
67        && identifier_properties.len() == 1
68        && identifier_values.len() == 1
69    {
70        return format!(
71            "Only one {S3_BUCKET_POLICY_RESOURCE_TYPE} resource can target a given bucket; resources {rendered_resources} all target bucket {}",
72            quote(&identifier_values[0])
73        );
74    }
75
76    let rendered_identifier = render_primary_identifier(identifier_properties, identifier_values);
77    format!(
78        "Primary identifiers {rendered_identifier} should have unique values across the resources {rendered_resources}"
79    )
80}
81
82fn render_primary_identifier(identifier_properties: &[String], identifier_values: &[String]) -> String {
83    let entries = identifier_properties
84        .iter()
85        .zip(identifier_values)
86        .map(|(property, value)| format!("{}: {}", quote(property), quote(value)))
87        .collect::<Vec<_>>()
88        .join(", ");
89    format!("{{{entries}}}")
90}
91
92fn render_string_set(values: &BTreeSet<String>) -> String {
93    let rendered = values.iter().map(quote).collect::<Vec<_>>().join(", ");
94    format!("{{{rendered}}}")
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use serde_json::json;
101
102    #[test]
103    fn quote_wraps_in_single_quotes() {
104        assert_eq!(quote("foo"), "'foo'");
105        assert_eq!(quote(String::from("bar")), "'bar'");
106    }
107
108    #[test]
109    fn render_value_single_quotes_strings() {
110        assert_eq!(render_value(&json!("hello")), "'hello'");
111    }
112
113    #[test]
114    fn render_value_renders_scalars_bare() {
115        assert_eq!(render_value(&json!(42)), "42");
116        assert_eq!(render_value(&json!(3.5)), "3.5");
117        assert_eq!(render_value(&json!(true)), "true");
118        assert_eq!(render_value(&json!(null)), "null");
119    }
120
121    #[test]
122    fn render_value_array_uses_single_quotes_not_double() {
123        let rendered = render_value(&json!(["a", "b"]));
124        assert_eq!(rendered, "['a', 'b']");
125        assert!(!rendered.contains('"'), "rendered list must not contain double quotes");
126    }
127
128    #[test]
129    fn render_value_object_uses_single_quotes() {
130        let rendered = render_value(&json!({"BucketName": "shared"}));
131        assert_eq!(rendered, "{'BucketName': 'shared'}");
132        assert!(!rendered.contains('"'));
133    }
134
135    #[test]
136    fn render_value_nested_mixed_types() {
137        assert_eq!(render_value(&json!([1, "two", true])), "[1, 'two', true]");
138    }
139
140    #[test]
141    fn render_value_list_matches_render_value_of_array() {
142        let values = vec![json!("x"), json!("y")];
143        assert_eq!(render_value_list(&values), "['x', 'y']");
144    }
145
146    #[test]
147    fn render_str_list_single_quotes_each_item() {
148        assert_eq!(render_str_list(["Enabled", "Suspended"]), "['Enabled', 'Suspended']");
149        assert_eq!(render_str_list(Vec::<String>::new()), "[]");
150    }
151
152    #[test]
153    fn bucket_policy_conflict_message_explains_the_shared_target() {
154        let properties = vec!["Bucket".to_string()];
155        let values = vec!["Ref(\"SharedBucket\")".to_string()];
156        let resources = BTreeSet::from(["FirstPolicy".to_string(), "SecondPolicy".to_string()]);
157
158        let message = primary_identifier_conflict_message("AWS::S3::BucketPolicy", &properties, &values, &resources);
159
160        assert_eq!(
161            message,
162            "Only one AWS::S3::BucketPolicy resource can target a given bucket; resources {'FirstPolicy', 'SecondPolicy'} all target bucket 'Ref(\"SharedBucket\")'"
163        );
164    }
165
166    #[test]
167    fn non_bucket_policy_conflict_message_preserves_generic_wording() {
168        let properties = vec!["BucketName".to_string()];
169        let values = vec!["shared".to_string()];
170        let resources = BTreeSet::from(["FirstBucket".to_string(), "SecondBucket".to_string()]);
171
172        let message = primary_identifier_conflict_message("AWS::S3::Bucket", &properties, &values, &resources);
173
174        assert_eq!(
175            message,
176            "Primary identifiers {'BucketName': 'shared'} should have unique values across the resources {'FirstBucket', 'SecondBucket'}"
177        );
178    }
179}