Skip to main content

template_model/
template_section.rs

1use serde::{Deserialize, Serialize};
2
3/// A top-level CloudFormation template section, as documented in the template
4/// anatomy (<https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-anatomy.html>).
5///
6/// This is the canonical, single definition of the section names - section
7/// constants in other crates derive from it.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
9#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
10#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
11pub enum TopLevelSection {
12    Resources,
13    Parameters,
14    Outputs,
15    Mappings,
16    Metadata,
17    Rules,
18    Conditions,
19    Transform,
20    FormatVersion,
21    Description,
22}
23
24impl TopLevelSection {
25    const ALL: [TopLevelSection; 10] = [
26        TopLevelSection::Resources,
27        TopLevelSection::Parameters,
28        TopLevelSection::Outputs,
29        TopLevelSection::Mappings,
30        TopLevelSection::Metadata,
31        TopLevelSection::Rules,
32        TopLevelSection::Conditions,
33        TopLevelSection::Transform,
34        TopLevelSection::FormatVersion,
35        TopLevelSection::Description,
36    ];
37
38    /// The section's key as written in a template.
39    pub const fn name(self) -> &'static str {
40        match self {
41            TopLevelSection::Resources => "Resources",
42            TopLevelSection::Parameters => "Parameters",
43            TopLevelSection::Outputs => "Outputs",
44            TopLevelSection::Mappings => "Mappings",
45            TopLevelSection::Metadata => "Metadata",
46            TopLevelSection::Rules => "Rules",
47            TopLevelSection::Conditions => "Conditions",
48            TopLevelSection::Transform => "Transform",
49            TopLevelSection::FormatVersion => "AWSTemplateFormatVersion",
50            TopLevelSection::Description => "Description",
51        }
52    }
53
54    /// Parses a template key into its section - the inverse of [`Self::name`].
55    pub fn from_name(name: &str) -> Option<TopLevelSection> {
56        Self::ALL.into_iter().find(|section| section.name() == name)
57    }
58
59    /// The entity type of this section's children.
60    pub const fn entity_type(self) -> EntityType {
61        match self {
62            TopLevelSection::Resources => EntityType::Resource,
63            TopLevelSection::Parameters => EntityType::Parameter,
64            TopLevelSection::Outputs => EntityType::Output,
65            TopLevelSection::Mappings => EntityType::Mapping,
66            TopLevelSection::Metadata => EntityType::Metadata,
67            TopLevelSection::Rules => EntityType::Rule,
68            TopLevelSection::Conditions => EntityType::Condition,
69            TopLevelSection::Transform => EntityType::Transform,
70            TopLevelSection::FormatVersion => EntityType::FormatVersion,
71            TopLevelSection::Description => EntityType::Description,
72        }
73    }
74}
75
76/// The kind of template entity a diagnostic targets - the singular form of the
77/// top-level section the entity is declared in. Every documented section has a
78/// variant; the ones whose children are addressable by logical ID (resources,
79/// parameters, outputs, mappings, conditions, rules, and metadata keys) are
80/// the ones diagnostics attribute findings to today.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
82#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
83#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
84pub enum EntityType {
85    Resource,
86    Parameter,
87    Output,
88    Mapping,
89    Metadata,
90    Rule,
91    Condition,
92    Transform,
93    FormatVersion,
94    Description,
95}
96
97impl EntityType {
98    const ALL: [EntityType; 10] = [
99        EntityType::Resource,
100        EntityType::Parameter,
101        EntityType::Output,
102        EntityType::Mapping,
103        EntityType::Metadata,
104        EntityType::Rule,
105        EntityType::Condition,
106        EntityType::Transform,
107        EntityType::FormatVersion,
108        EntityType::Description,
109    ];
110
111    /// The entity type's name - the singular form used in serialized
112    /// diagnostics and filter configurations.
113    pub const fn name(self) -> &'static str {
114        match self {
115            EntityType::Resource => "Resource",
116            EntityType::Parameter => "Parameter",
117            EntityType::Output => "Output",
118            EntityType::Mapping => "Mapping",
119            EntityType::Metadata => "Metadata",
120            EntityType::Rule => "Rule",
121            EntityType::Condition => "Condition",
122            EntityType::Transform => "Transform",
123            EntityType::FormatVersion => "FormatVersion",
124            EntityType::Description => "Description",
125        }
126    }
127
128    /// The top-level template section this kind of entity is declared in.
129    pub const fn section(self) -> TopLevelSection {
130        match self {
131            EntityType::Resource => TopLevelSection::Resources,
132            EntityType::Parameter => TopLevelSection::Parameters,
133            EntityType::Output => TopLevelSection::Outputs,
134            EntityType::Mapping => TopLevelSection::Mappings,
135            EntityType::Metadata => TopLevelSection::Metadata,
136            EntityType::Rule => TopLevelSection::Rules,
137            EntityType::Condition => TopLevelSection::Conditions,
138            EntityType::Transform => TopLevelSection::Transform,
139            EntityType::FormatVersion => TopLevelSection::FormatVersion,
140            EntityType::Description => TopLevelSection::Description,
141        }
142    }
143
144    /// Maps a top-level template section name to the entity type of its
145    /// children. Returns `None` for keys that are not documented sections.
146    pub fn from_section(section: &str) -> Option<EntityType> {
147        TopLevelSection::from_name(section).map(TopLevelSection::entity_type)
148    }
149}
150
151impl std::str::FromStr for EntityType {
152    type Err = String;
153
154    fn from_str(s: &str) -> Result<Self, Self::Err> {
155        Self::ALL.into_iter().find(|entity_type| entity_type.name().eq_ignore_ascii_case(s)).ok_or_else(|| {
156            format!("Invalid entity type '{s}'; expected one of: {}", Self::ALL.map(EntityType::name).join(", "))
157        })
158    }
159}
160
161/// Splits a section-absolute, slash-separated template path (such as
162/// `Parameters/MyParam/Type` or `Outputs/MyOutput/Value`) into the entity type
163/// of the section's children and the logical ID of the entity it addresses.
164/// Returns `None` for paths that are not rooted at a documented top-level
165/// section - resource-relative dotted paths like `Properties.BucketName` - or
166/// that name a section with no child segment.
167pub fn entity_identity(path: &str) -> Option<(EntityType, &str)> {
168    let mut segments = path.split('/');
169    let entity_type = EntityType::from_section(segments.next()?)?;
170    let logical_id = segments.next().filter(|id| !id.is_empty())?;
171    Some((entity_type, logical_id))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn section_names_round_trip_through_from_name() {
180        for section in TopLevelSection::ALL {
181            assert_eq!(TopLevelSection::from_name(section.name()), Some(section), "{section:?}");
182        }
183        assert_eq!(TopLevelSection::from_name("Globals"), None, "SAM Globals is not a documented section");
184        assert_eq!(TopLevelSection::from_name("Constants"), None);
185        assert_eq!(TopLevelSection::from_name(""), None);
186    }
187
188    #[test]
189    fn format_version_uses_the_full_template_key() {
190        assert_eq!(TopLevelSection::FormatVersion.name(), "AWSTemplateFormatVersion");
191        assert_eq!(TopLevelSection::from_name("AWSTemplateFormatVersion"), Some(TopLevelSection::FormatVersion));
192    }
193
194    #[test]
195    fn entity_type_and_section_are_inverse_mappings() {
196        for section in TopLevelSection::ALL {
197            assert_eq!(section.entity_type().section(), section, "{section:?}");
198        }
199    }
200
201    #[test]
202    fn from_section_maps_names_to_singular_entity_types() {
203        assert_eq!(EntityType::from_section("Resources"), Some(EntityType::Resource));
204        assert_eq!(EntityType::from_section("Parameters"), Some(EntityType::Parameter));
205        assert_eq!(EntityType::from_section("Outputs"), Some(EntityType::Output));
206        assert_eq!(EntityType::from_section("Mappings"), Some(EntityType::Mapping));
207        assert_eq!(EntityType::from_section("Conditions"), Some(EntityType::Condition));
208        assert_eq!(EntityType::from_section("Rules"), Some(EntityType::Rule));
209        assert_eq!(EntityType::from_section("Metadata"), Some(EntityType::Metadata));
210        assert_eq!(EntityType::from_section("Transform"), Some(EntityType::Transform));
211        assert_eq!(EntityType::from_section("AWSTemplateFormatVersion"), Some(EntityType::FormatVersion));
212        assert_eq!(EntityType::from_section("Description"), Some(EntityType::Description));
213        assert_eq!(EntityType::from_section("Resource"), None, "singular form is not a section name");
214    }
215
216    #[test]
217    fn entity_identity_splits_section_absolute_paths() {
218        assert_eq!(entity_identity("Parameters/MyParam/Type"), Some((EntityType::Parameter, "MyParam")));
219        assert_eq!(entity_identity("Parameters/MyParam"), Some((EntityType::Parameter, "MyParam")));
220        assert_eq!(entity_identity("Outputs/MyOutput/Value"), Some((EntityType::Output, "MyOutput")));
221        assert_eq!(entity_identity("Mappings/MyMap/Key1/Key2"), Some((EntityType::Mapping, "MyMap")));
222        assert_eq!(entity_identity("Conditions/IsProd"), Some((EntityType::Condition, "IsProd")));
223        assert_eq!(entity_identity("Rules/MyRule/Assertions/0"), Some((EntityType::Rule, "MyRule")));
224        assert_eq!(entity_identity("Resources/MyBucket/Properties/Name"), Some((EntityType::Resource, "MyBucket")));
225    }
226
227    #[test]
228    fn entity_identity_handles_mixed_dot_segments_after_the_entity() {
229        // Builder-style paths mix slash and dot separators past the entity;
230        // only the first two slash segments matter for identity.
231        assert_eq!(entity_identity("Outputs/X/Value.Fn::If.1"), Some((EntityType::Output, "X")));
232    }
233
234    #[test]
235    fn entity_identity_rejects_non_entity_paths() {
236        assert_eq!(entity_identity("Properties.BucketName"), None, "resource-relative dotted path");
237        assert_eq!(entity_identity("Parameters"), None, "bare section has no entity");
238        assert_eq!(entity_identity("Description"), None, "bare section has no entity");
239        assert_eq!(entity_identity("AWSTemplateFormatVersion"), None, "bare section has no entity");
240        assert_eq!(entity_identity("Globals/Function"), None, "SAM Globals is not a documented section");
241        assert_eq!(entity_identity(""), None);
242        assert_eq!(entity_identity("Parameters//Type"), None, "empty entity segment");
243    }
244}