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#[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 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 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 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 pub fn location(mut self, span: SourceSpan) -> Self {
71 self.location = span_to_option(span);
72 self
73 }
74
75 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 pub fn condition_scenario(mut self, condition_scenario: Option<HashMap<String, bool>>) -> Self {
83 self.condition_scenario = condition_scenario;
84 self
85 }
86
87 pub fn phase(mut self, phase: Phase) -> Self {
90 self.phase = Some(phase);
91 self
92 }
93
94 pub fn related_resources(mut self, related_resources: Option<Vec<RelatedResource>>) -> Self {
96 self.related_resources = related_resources;
97 self
98 }
99
100 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
125pub 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}