Skip to main content

diagnostics/
builder.rs

1use crate::diagnostic::{Diagnostic, Entity, RelatedResource};
2use crate::phase::Phase;
3use rules::lookup_rule;
4use std::collections::HashMap;
5use template_model::{ParseDefect, SourceSpan, span_to_option};
6
7/// Builds a [`Diagnostic`] for a rule that is registered in the rule registry.
8///
9/// Severity, category, origin, and description are taken from the registry entry
10/// for `rule_id` - this is the single place those four fields are sourced for
11/// built-in diagnostics, so every built-in finding is consistent regardless of
12/// which crate produced it. [`build`](Self::build) panics if the rule is not
13/// registered, turning an unregistered built-in rule into a loud failure instead
14/// of a silently inconsistent diagnostic.
15///
16/// Custom and Guard rules are intentionally absent from the registry; they carry
17/// their own severity, category, and origin from the parsed rule definition and
18/// must construct diagnostics directly rather than through this builder.
19#[must_use = "RegisteredDiagnostic does nothing until `build` is called"]
20pub struct RegisteredDiagnostic {
21    rule_id: String,
22    message: String,
23    entity: Option<Entity>,
24    property_path: Option<String>,
25    location: Option<SourceSpan>,
26    suggested_fix: Option<String>,
27    condition_scenario: Option<HashMap<String, bool>>,
28    phase: Option<Phase>,
29    related_resources: Option<Vec<RelatedResource>>,
30}
31
32impl RegisteredDiagnostic {
33    /// Starts a diagnostic for `rule_id` carrying `message`. All other fields
34    /// default to absent and are added through the builder methods.
35    pub fn new(rule_id: impl Into<String>, message: impl Into<String>) -> Self {
36        Self {
37            rule_id: rule_id.into(),
38            message: message.into(),
39            entity: None,
40            property_path: None,
41            location: None,
42            suggested_fix: None,
43            condition_scenario: None,
44            phase: None,
45            related_resources: None,
46        }
47    }
48
49    /// Attaches the offending resource as the targeted entity: its logical ID
50    /// and, when known, its CloudFormation type. An empty ID is dropped so
51    /// callers can pass through an ID that may be blank - mirrors
52    /// [`property_path`](Self::property_path).
53    pub fn resource(mut self, resource_id: impl Into<String>, resource_type: Option<String>) -> Self {
54        self.entity = Entity::resource(resource_id, resource_type);
55        self
56    }
57
58    /// Records the property path within the resource. An empty path is ignored
59    /// so callers can pass through a path that may be blank.
60    pub fn property_path(mut self, property_path: impl Into<String>) -> Self {
61        let path = property_path.into();
62        if !path.is_empty() {
63            self.property_path = Some(path);
64        }
65        self
66    }
67
68    /// Sets the source span. [`UNKNOWN_SPAN`](template_model::UNKNOWN_SPAN) is
69    /// treated as "no location".
70    pub fn location(mut self, span: SourceSpan) -> Self {
71        self.location = span_to_option(span);
72        self
73    }
74
75    /// Sets an optional suggested fix.
76    pub fn suggested_fix(mut self, suggested_fix: Option<impl Into<String>>) -> Self {
77        self.suggested_fix = suggested_fix.map(Into::into);
78        self
79    }
80
81    /// Sets the condition scenario under which the diagnostic applies.
82    pub fn condition_scenario(mut self, condition_scenario: Option<HashMap<String, bool>>) -> Self {
83        self.condition_scenario = condition_scenario;
84        self
85    }
86
87    /// Pins the pipeline phase. When left unset, downstream enrichment derives
88    /// the phase from the rule's severity.
89    pub fn phase(mut self, phase: Phase) -> Self {
90        self.phase = Some(phase);
91        self
92    }
93
94    /// Attaches related resource references (cross-resource findings).
95    pub fn related_resources(mut self, related_resources: Option<Vec<RelatedResource>>) -> Self {
96        self.related_resources = related_resources;
97        self
98    }
99
100    /// Assembles the [`Diagnostic`], sourcing severity, category, origin, and
101    /// description from the rule registry. Panics if `rule_id` is not registered.
102    pub fn build(self) -> Diagnostic {
103        let definition =
104            lookup_rule(&self.rule_id).unwrap_or_else(|| panic!("Rule '{}' not found in RULE_REGISTRY", self.rule_id));
105        Diagnostic {
106            rule_id: self.rule_id,
107            severity: definition.severity(),
108            message: self.message,
109            entity: self.entity,
110            property_path: self.property_path,
111            suggested_fix: self.suggested_fix,
112            documentation_url: None,
113            category: Some(definition.category.as_str().into()),
114            location: self.location,
115            related_resources: self.related_resources,
116            condition_scenario: self.condition_scenario,
117            rule_description: Some(definition.description.into()),
118            phase: self.phase,
119            context: None,
120            source: definition.origin,
121        }
122    }
123}
124
125/// Converts a parse-time defect from the template model into a full
126/// [`Diagnostic`], sourcing severity, category, origin, and description from
127/// the rule registry. This is the single boundary where the model's plain
128/// findings acquire reporting metadata; like [`RegisteredDiagnostic::build`],
129/// it panics if the defect's rule ID is not registered.
130pub fn diagnostic_from_parse_defect(defect: &ParseDefect) -> Diagnostic {
131    let mut builder = RegisteredDiagnostic::new(defect.rule_id.clone(), defect.message.clone()).location(defect.span);
132    if let Some(resource_id) = &defect.resource_id {
133        builder = builder.resource(resource_id.clone(), None);
134    }
135    if let Some(property_path) = &defect.property_path {
136        builder = builder.property_path(property_path.clone());
137    }
138    if let Some(phase) = defect.phase {
139        builder = builder.phase(phase.into());
140    }
141    builder.build()
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use rules::Severity;
148    use template_model::{DefectPhase, UNKNOWN_SPAN};
149
150    #[test]
151    fn build_sources_severity_category_origin_and_description_from_registry() {
152        let definition = lookup_rule("E3012").expect("E3012 is registered");
153        let diagnostic = RegisteredDiagnostic::new("E3012", "Type mismatch").build();
154
155        assert_eq!(diagnostic.rule_id, "E3012");
156        assert_eq!(diagnostic.severity, Severity::Error, "severity derived from the E prefix via the registry");
157        assert_eq!(
158            diagnostic.category.as_deref(),
159            Some(definition.category.as_str()),
160            "category taken from the registry entry"
161        );
162        assert_eq!(diagnostic.source, definition.origin, "origin taken from the registry entry");
163        assert!(diagnostic.rule_description.is_some(), "description taken from the registry entry");
164    }
165
166    #[test]
167    fn fatal_severity_is_derived_for_f_prefixed_rules() {
168        let diagnostic = RegisteredDiagnostic::new("F0001", "Resources section missing").build();
169        assert_eq!(diagnostic.severity, Severity::Fatal);
170    }
171
172    #[test]
173    fn resource_and_property_path_are_attached_when_present() {
174        let diagnostic = RegisteredDiagnostic::new("E3012", "msg")
175            .resource("MyBucket", Some("AWS::S3::Bucket".into()))
176            .property_path("Properties.BucketName")
177            .build();
178
179        let entity = diagnostic.entity.expect("entity should be set");
180        assert_eq!(entity.logical_id, "MyBucket");
181        assert_eq!(entity.entity_type, template_model::EntityType::Resource);
182        assert_eq!(entity.resource_type.as_deref(), Some("AWS::S3::Bucket"));
183        assert_eq!(diagnostic.property_path.as_deref(), Some("Properties.BucketName"));
184    }
185
186    #[test]
187    fn empty_resource_id_yields_no_entity() {
188        let diagnostic = RegisteredDiagnostic::new("F0001", "msg").resource("", None).build();
189        assert!(diagnostic.entity.is_none(), "an empty resource ID must not create an entity");
190    }
191
192    #[test]
193    fn empty_property_path_is_dropped() {
194        let diagnostic = RegisteredDiagnostic::new("F0001", "msg").property_path("").build();
195        assert_eq!(diagnostic.property_path, None, "an empty property path must not be recorded");
196    }
197
198    #[test]
199    fn unknown_span_becomes_no_location() {
200        let diagnostic = RegisteredDiagnostic::new("F0001", "msg").location(UNKNOWN_SPAN).build();
201        assert_eq!(diagnostic.location, None, "UNKNOWN_SPAN must map to no location");
202    }
203
204    #[test]
205    fn known_span_is_preserved() {
206        let span = SourceSpan { start_line: 3, start_column: 1, end_line: 3, end_column: 9 };
207        let diagnostic = RegisteredDiagnostic::new("F0001", "msg").location(span).build();
208        assert_eq!(diagnostic.location, Some(span));
209    }
210
211    #[test]
212    fn phase_is_pinned_when_set() {
213        let diagnostic = RegisteredDiagnostic::new("F0001", "msg").phase(Phase::Parse).build();
214        assert_eq!(diagnostic.phase, Some(Phase::Parse));
215    }
216
217    #[test]
218    #[should_panic(expected = "not found in RULE_REGISTRY")]
219    fn build_panics_for_unregistered_rule() {
220        let _ = RegisteredDiagnostic::new("Z9999", "unregistered").build();
221    }
222
223    #[test]
224    fn parse_defect_conversion_carries_anchors_and_registry_metadata() {
225        let span = SourceSpan { start_line: 7, start_column: 3, end_line: 7, end_column: 9 };
226        let defect = ParseDefect::new("F0001", "Resources section missing")
227            .location(span)
228            .resource("MyBucket")
229            .property_path("Properties.BucketName")
230            .phase(DefectPhase::Parse);
231
232        let diagnostic = diagnostic_from_parse_defect(&defect);
233        assert_eq!(diagnostic.rule_id, "F0001");
234        assert_eq!(diagnostic.severity, Severity::Fatal, "severity sourced from the registry");
235        assert_eq!(diagnostic.location, Some(span));
236        assert_eq!(diagnostic.entity.as_ref().map(|e| e.logical_id.as_str()), Some("MyBucket"));
237        assert_eq!(diagnostic.property_path.as_deref(), Some("Properties.BucketName"));
238        assert_eq!(diagnostic.phase, Some(Phase::Parse));
239    }
240
241    #[test]
242    fn parse_defect_conversion_leaves_unset_fields_absent() {
243        let defect = ParseDefect::new("F0001", "msg").location(UNKNOWN_SPAN);
244        let diagnostic = diagnostic_from_parse_defect(&defect);
245        assert_eq!(diagnostic.location, None, "UNKNOWN_SPAN must map to no location");
246        assert!(diagnostic.entity.is_none());
247        assert_eq!(diagnostic.property_path, None);
248        assert_eq!(diagnostic.phase, None, "an unset phase is derived downstream");
249    }
250
251    #[test]
252    fn lint_stage_defects_convert_to_the_lint_phase() {
253        let defect = ParseDefect::new("F3004", "cycle").phase(DefectPhase::Lint);
254        assert_eq!(diagnostic_from_parse_defect(&defect).phase, Some(Phase::Lint));
255    }
256}