1use crate::filter::Filterable;
2use crate::metrics::PhaseMetric;
3use crate::phase::Phase;
4use rules::{RuleOrigin, Severity};
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, HashMap};
7use template_model::{EntityType, JsonValue, SourceSpan};
8
9fn serialize_sorted_optional_map<S, V>(map: &Option<HashMap<String, V>>, serializer: S) -> Result<S::Ok, S::Error>
10where
11 S: serde::Serializer,
12 V: Serialize,
13{
14 match map {
15 Some(m) => {
16 let sorted: BTreeMap<&String, &V> = m.iter().collect();
17 sorted.serialize(serializer)
18 }
19 None => serializer.serialize_none(),
20 }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
26#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
27#[serde(rename_all = "camelCase")]
28pub struct ResourceRef {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
32 pub id: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
35 pub resource_type: Option<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
42#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
43#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
44#[serde(rename_all = "camelCase")]
45pub struct Entity {
46 pub logical_id: String,
48 pub entity_type: EntityType,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
53 pub resource_type: Option<String>,
54}
55
56impl Entity {
57 pub fn resource(logical_id: impl Into<String>, resource_type: Option<String>) -> Option<Entity> {
60 let logical_id = logical_id.into();
61 if logical_id.is_empty() {
62 return None;
63 }
64 Some(Entity { logical_id, entity_type: EntityType::Resource, resource_type })
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
71#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
72#[serde(rename_all = "camelCase")]
73pub struct ViolationContext {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 #[cfg_attr(feature = "wasm-bindings", tsify(type = "JsonValue"))]
77 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
78 pub actual_value: Option<JsonValue>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
82 pub expected_constraint: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
86 pub property: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
90 pub lifecycle: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
94 pub resolution_source: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "serialize_sorted_optional_map")]
97 #[cfg_attr(feature = "wasm-bindings", tsify(type = "Record<string, JsonValue>"))]
98 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
99 pub extra: Option<HashMap<String, JsonValue>>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
105#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
106#[serde(rename_all = "camelCase")]
107pub struct RelatedResource {
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
110 pub resource: Option<ResourceRef>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
114 pub location: Option<SourceSpan>,
115 pub message: String,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct Diagnostic {
121 pub rule_id: String,
122 pub severity: Severity,
123 pub message: String,
124 pub source: RuleOrigin,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub entity: Option<Entity>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub property_path: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub suggested_fix: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub documentation_url: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub category: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub location: Option<SourceSpan>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub related_resources: Option<Vec<RelatedResource>>,
140 #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "serialize_sorted_optional_map")]
141 pub condition_scenario: Option<HashMap<String, bool>>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub rule_description: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub phase: Option<Phase>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub context: Option<ViolationContext>,
148}
149
150impl Diagnostic {
151 pub fn resource_logical_id(&self) -> Option<&str> {
153 self.entity.as_ref().filter(|e| e.entity_type == EntityType::Resource).map(|e| e.logical_id.as_str())
154 }
155}
156
157impl Filterable for Diagnostic {
158 fn rule_id(&self) -> &str {
159 &self.rule_id
160 }
161 fn category(&self) -> Option<&str> {
162 self.category.as_deref()
163 }
164 fn resource_id(&self) -> Option<&str> {
165 self.resource_logical_id()
166 }
167 fn resource_type(&self) -> Option<&str> {
168 self.entity.as_ref().and_then(|e| e.resource_type.as_deref())
169 }
170 fn logical_id(&self) -> Option<&str> {
171 self.entity.as_ref().map(|e| e.logical_id.as_str())
172 }
173 fn entity_type(&self) -> Option<EntityType> {
174 self.entity.as_ref().map(|e| e.entity_type)
175 }
176}
177
178macro_rules! define_flattened_diagnostic {
182 ($(#[$struct_meta:meta])* $name:ident $(, $(#[$extra_meta:meta])* $extra_field:ident : $extra_ty:ty)*) => {
183 $(#[$struct_meta])*
184 #[derive(Debug, Clone, Serialize, Deserialize)]
185 #[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
186 #[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
187 #[serde(rename_all = "camelCase")]
188 pub struct $name {
189 pub rule_id: String,
191 pub severity: Severity,
192 pub message: String,
193 pub source: RuleOrigin,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
198 pub entity: Option<Entity>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
202 pub property_path: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
205 pub suggested_fix: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
208 pub category: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
212 pub start_line: Option<u32>,
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
215 pub start_column: Option<u32>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
218 pub end_line: Option<u32>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
221 pub end_column: Option<u32>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
223 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
224 pub related_resources: Option<Vec<RelatedResource>>,
225 #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "serialize_sorted_optional_map")]
227 #[cfg_attr(feature = "wasm-bindings", tsify(type = "Record<string, boolean>"))]
228 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
229 pub condition_scenario: Option<HashMap<String, bool>>,
230 $(
231 $(#[$extra_meta])*
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
234 pub $extra_field: $extra_ty,
235 )*
236 }
237 };
238}
239
240define_flattened_diagnostic!(
241 StandardDiagnostic
243);
244define_flattened_diagnostic!(
245 DetailedDiagnostic,
247 documentation_url: Option<String>,
248 rule_description: Option<String>,
249 phase: Option<Phase>,
250 context: Option<ViolationContext>
251);
252
253macro_rules! flatten_diagnostic {
255 ($self:expr $(, $extra_field:ident)* ) => {{
256 let (start_line, start_column, end_line, end_column) = $self
257 .location
258 .map(|l| (Some(l.start_line), Some(l.start_column), Some(l.end_line), Some(l.end_column)))
259 .unwrap_or((None, None, None, None));
260 (
261 $self.rule_id.clone(),
262 $self.severity,
263 $self.message.clone(),
264 $self.entity.clone(),
265 $self.property_path.clone(),
266 $self.suggested_fix.clone(),
267 $self.category.clone(),
268 start_line,
269 start_column,
270 end_line,
271 end_column,
272 $self.related_resources.clone(),
273 $self.condition_scenario.clone(),
274 $self.source,
275 $( $self.$extra_field.clone(), )*
276 )
277 }};
278}
279
280impl Diagnostic {
281 pub fn to_standard(&self) -> StandardDiagnostic {
282 let (
283 rule_id,
284 severity,
285 message,
286 entity,
287 property_path,
288 suggested_fix,
289 category,
290 start_line,
291 start_column,
292 end_line,
293 end_column,
294 related_resources,
295 condition_scenario,
296 source,
297 ) = flatten_diagnostic!(self);
298 StandardDiagnostic {
299 rule_id,
300 severity,
301 message,
302 entity,
303 property_path,
304 suggested_fix,
305 category,
306 start_line,
307 start_column,
308 end_line,
309 end_column,
310 related_resources,
311 condition_scenario,
312 source,
313 }
314 }
315
316 pub fn to_detailed(&self) -> DetailedDiagnostic {
317 let (
318 rule_id,
319 severity,
320 message,
321 entity,
322 property_path,
323 suggested_fix,
324 category,
325 start_line,
326 start_column,
327 end_line,
328 end_column,
329 related_resources,
330 condition_scenario,
331 source,
332 documentation_url,
333 rule_description,
334 phase,
335 context,
336 ) = flatten_diagnostic!(self, documentation_url, rule_description, phase, context);
337 DetailedDiagnostic {
338 rule_id,
339 severity,
340 message,
341 entity,
342 property_path,
343 suggested_fix,
344 category,
345 start_line,
346 start_column,
347 end_line,
348 end_column,
349 related_resources,
350 condition_scenario,
351 source,
352 documentation_url,
353 rule_description,
354 phase,
355 context,
356 }
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
362#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
363#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
364#[serde(rename_all = "camelCase")]
365pub struct PerformanceMetrics {
366 pub schema_init: PhaseMetric,
368 pub engine_init: PhaseMetric,
370 pub model_build: PhaseMetric,
372 pub schema_validate: PhaseMetric,
373 pub rule_evaluation: PhaseMetric,
374 pub diagnostic_finalize: PhaseMetric,
376 pub validate_total: PhaseMetric,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
382#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
383#[serde(rename_all = "camelCase")]
384pub struct BudgetExhaustionRecord {
385 pub kind: String,
387 #[serde(default)]
389 pub description: String,
390 pub limit: u64,
392 pub analysis_incomplete: bool,
394}
395
396fn budget_exhaustions_are_absent_or_empty(records: &Option<Vec<BudgetExhaustionRecord>>) -> bool {
397 records.as_ref().is_none_or(Vec::is_empty)
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
401#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
402#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
403#[serde(rename_all = "camelCase")]
404pub struct ReportMetadata {
405 pub rules_evaluated: u32,
407 pub cfn_lint_version: String,
409 pub resource_schema_version: String,
411 pub resources_scanned: u32,
412 pub counts: Summary,
414 pub suppressed: u32,
416 pub strict: bool,
418 pub severity_level: Severity,
420 #[serde(default, skip_serializing_if = "budget_exhaustions_are_absent_or_empty")]
423 pub budget_exhaustions: Option<Vec<BudgetExhaustionRecord>>,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
428#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
429#[serde(rename_all = "camelCase")]
430pub struct Summary {
431 pub fatal: u32,
432 pub errors: u32,
433 pub warnings: u32,
434 pub informational: u32,
435 pub debug: u32,
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
444#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
445#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
446pub enum ReportStatus {
447 Ok,
448 AnalysisIncomplete,
449 Error,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(rename_all = "camelCase")]
454#[must_use]
455pub struct ValidationReport {
456 pub file_path: String,
457 pub status: ReportStatus,
458 pub version: String,
459 pub metadata: ReportMetadata,
460 pub performance: PerformanceMetrics,
461 pub diagnostics: Vec<Diagnostic>,
462}
463
464impl ValidationReport {
465 pub fn to_standard(&self) -> StandardReport {
466 StandardReport {
467 file_path: self.file_path.clone(),
468 status: self.status,
469 version: self.version.clone(),
470 diagnostics: self.diagnostics.iter().map(|d| d.to_standard()).collect(),
471 metadata: self.metadata.clone(),
472 performance: self.performance.clone(),
473 }
474 }
475
476 pub fn to_detailed(&self) -> DetailedReport {
477 DetailedReport {
478 file_path: self.file_path.clone(),
479 status: self.status,
480 version: self.version.clone(),
481 diagnostics: self.diagnostics.iter().map(|d| d.to_detailed()).collect(),
482 metadata: self.metadata.clone(),
483 performance: self.performance.clone(),
484 }
485 }
486}
487
488#[derive(Debug, Clone, Serialize)]
490#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
491#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
492#[serde(rename_all = "camelCase")]
493pub struct StandardReport {
494 pub file_path: String,
495 pub status: ReportStatus,
496 pub version: String,
497 pub metadata: ReportMetadata,
498 pub performance: PerformanceMetrics,
499 pub diagnostics: Vec<StandardDiagnostic>,
500}
501
502#[derive(Debug, Clone, Serialize)]
504#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
505#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
506#[serde(rename_all = "camelCase")]
507pub struct DetailedReport {
508 pub file_path: String,
509 pub status: ReportStatus,
510 pub version: String,
511 pub metadata: ReportMetadata,
512 pub performance: PerformanceMetrics,
513 pub diagnostics: Vec<DetailedDiagnostic>,
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 fn sample_diagnostic() -> Diagnostic {
521 Diagnostic {
522 rule_id: "E3012".into(),
523 severity: Severity::Error,
524 message: "Property not allowed".into(),
525 entity: Entity::resource("MyBucket", Some("AWS::S3::Bucket".into())),
526 property_path: Some("/Resources/MyBucket/Properties/Foo".into()),
527 suggested_fix: Some("Remove the property".into()),
528 documentation_url: Some("https://example.com/E3012".into()),
529 category: Some("schema".into()),
530 location: Some(SourceSpan { start_line: 10, start_column: 5, end_line: 10, end_column: 20 }),
531 related_resources: Some(vec![RelatedResource {
532 resource: Some(ResourceRef {
533 id: Some("OtherResource".into()),
534 resource_type: Some("AWS::EC2::Instance".into()),
535 }),
536 location: Some(SourceSpan { start_line: 20, start_column: 1, end_line: 20, end_column: 30 }),
537 message: "Referenced here".into(),
538 }]),
539 condition_scenario: Some(HashMap::from([("IsProduction".into(), true)])),
540 rule_description: Some("Disallows extra properties".into()),
541 phase: Some(Phase::Schema),
542 context: Some(ViolationContext {
543 actual_value: Some(JsonValue::from(serde_json::json!("bad"))),
544 expected_constraint: Some("Must not exist".into()),
545 property: Some("Foo".into()),
546 lifecycle: None,
547 resolution_source: None,
548 extra: None,
549 }),
550 source: RuleOrigin::CfnLint,
551 }
552 }
553
554 fn minimal_diagnostic() -> Diagnostic {
555 Diagnostic {
556 rule_id: String::new(),
557 severity: Severity::Info,
558 message: String::new(),
559 entity: None,
560 property_path: None,
561 suggested_fix: None,
562 documentation_url: None,
563 category: None,
564 location: None,
565 related_resources: None,
566 condition_scenario: None,
567 rule_description: None,
568 phase: None,
569 context: None,
570 source: RuleOrigin::Engine,
571 }
572 }
573
574 #[test]
575 fn to_standard_carries_entity_and_flattens_location_fields() {
576 let d = sample_diagnostic();
577 let s = d.to_standard();
578
579 assert_eq!(s.rule_id, "E3012");
580 let entity = s.entity.as_ref().expect("entity should be present");
581 assert_eq!(entity.logical_id, "MyBucket");
582 assert_eq!(entity.entity_type, EntityType::Resource);
583 assert_eq!(entity.resource_type.as_deref(), Some("AWS::S3::Bucket"));
584 assert_eq!(s.start_line, Some(10));
585 assert_eq!(s.start_column, Some(5));
586 assert_eq!(s.end_line, Some(10));
587 assert_eq!(s.end_column, Some(20));
588 assert_eq!(s.message, "Property not allowed");
589 assert_eq!(s.category.as_deref(), Some("schema"));
590 assert_eq!(s.suggested_fix.as_deref(), Some("Remove the property"));
591 assert_eq!(s.related_resources.as_ref().unwrap().len(), 1);
592 assert_ne!(s.condition_scenario, None, "condition_scenario should be present");
593 }
594
595 #[test]
596 fn to_full_includes_context_and_enrichment_fields() {
597 let d = sample_diagnostic();
598 let f = d.to_detailed();
599
600 assert_eq!(f.rule_id, "E3012");
601 assert_eq!(f.entity.as_ref().map(|e| e.logical_id.as_str()), Some("MyBucket"));
602 assert!(f.context.is_some(), "full diagnostic should include context");
603 let ctx = f.context.unwrap();
604 assert_eq!(ctx.property.as_deref(), Some("Foo"));
605 assert_eq!(ctx.expected_constraint.as_deref(), Some("Must not exist"));
606 assert_eq!(f.phase, Some(Phase::Schema));
607 }
608
609 #[test]
610 fn filterable_reads_identity_through_the_entity() {
611 let d = sample_diagnostic();
612 assert_eq!(d.rule_id(), "E3012");
613 assert_eq!(d.category(), Some("schema"));
614 assert_eq!(d.resource_id(), Some("MyBucket"));
615 assert_eq!(d.resource_type(), Some("AWS::S3::Bucket"));
616 assert_eq!(d.logical_id(), Some("MyBucket"));
617 }
618
619 #[test]
620 fn filterable_resource_id_is_none_for_non_resource_entities() {
621 let mut d = sample_diagnostic();
622 d.entity =
623 Some(Entity { logical_id: "MyParam".into(), entity_type: EntityType::Parameter, resource_type: None });
624 assert_eq!(d.resource_id(), None, "a parameter is not a resource");
625 assert_eq!(d.resource_type(), None);
626 assert_eq!(d.logical_id(), Some("MyParam"));
627 }
628
629 #[test]
630 fn entity_serializes_camel_case_with_pascal_case_type_and_omits_absent_resource_type() {
631 let resource = Entity::resource("MyBucket", Some("AWS::S3::Bucket".into())).unwrap();
632 let json = serde_json::to_string(&resource).unwrap();
633 assert!(json.contains("\"logicalId\":\"MyBucket\""), "got: {json}");
634 assert!(json.contains("\"entityType\":\"Resource\""), "got: {json}");
635 assert!(json.contains("\"resourceType\":\"AWS::S3::Bucket\""), "got: {json}");
636
637 let parameter =
638 Entity { logical_id: "MyParam".into(), entity_type: EntityType::Parameter, resource_type: None };
639 let json = serde_json::to_string(¶meter).unwrap();
640 assert!(json.contains("\"entityType\":\"Parameter\""), "got: {json}");
641 assert!(!json.contains("resourceType"), "absent resourceType must be omitted, got: {json}");
642 }
643
644 #[test]
645 fn entity_resource_drops_empty_logical_id() {
646 assert!(Entity::resource("", None).is_none(), "an empty logical ID must not create an entity");
647 }
648
649 #[test]
650 fn diagnostic_serde_round_trips_all_fields() {
651 let d = sample_diagnostic();
652 let json = serde_json::to_string(&d).unwrap();
653 let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
654 assert_eq!(deserialized.rule_id, d.rule_id);
655 assert_eq!(deserialized.message, d.message);
656 assert_eq!(deserialized.severity, d.severity);
657 assert_eq!(deserialized.source, d.source);
658 assert_eq!(deserialized.entity.as_ref().map(|e| e.logical_id.as_str()), Some("MyBucket"));
659 assert_eq!(deserialized.location.as_ref().unwrap().start_line, d.location.as_ref().unwrap().start_line);
660 }
661
662 #[test]
663 fn standard_diagnostic_uses_camel_case_and_excludes_context() {
664 let d = sample_diagnostic();
665 let s = d.to_standard();
666 let json = serde_json::to_string(&s).unwrap();
667 assert!(json.contains("ruleId"), "expected camelCase 'ruleId' in JSON");
668 assert!(json.contains("startLine"), "expected 'startLine' in JSON");
669 assert!(json.contains("\"entity\""), "expected nested 'entity' in JSON");
670 assert!(json.contains("logicalId"), "expected 'logicalId' in JSON");
671 assert!(json.contains("entityType"), "expected 'entityType' in JSON");
672 assert!(json.contains("resourceType"), "expected 'resourceType' in JSON");
673 assert!(json.contains("propertyPath"), "expected 'propertyPath' in JSON");
674 assert!(!json.contains("\"context\""), "standard format should not include 'context'");
675 }
676
677 #[test]
678 fn full_diagnostic_includes_context_in_serialization() {
679 let d = sample_diagnostic();
680 let f = d.to_detailed();
681 let json = serde_json::to_string(&f).unwrap();
682 assert!(json.contains("\"context\""), "full format should include 'context'");
683 assert!(json.contains("actualValue"), "full format should include 'actualValue'");
684 assert!(json.contains("expectedConstraint"), "full format should include 'expectedConstraint'");
685 }
686
687 #[test]
688 fn none_fields_are_omitted_from_serialization() {
689 let d = minimal_diagnostic();
690 let json = serde_json::to_string(&d).unwrap();
691 assert!(!json.contains("suggestedFix"), "None suggestedFix should be omitted");
692 assert!(!json.contains("documentationUrl"), "None documentationUrl should be omitted");
693 assert!(!json.contains("context"), "None context should be omitted");
694 assert!(!json.contains("relatedResources"), "None relatedResources should be omitted");
695 assert!(!json.contains("conditionScenario"), "None conditionScenario should be omitted");
696 assert!(!json.contains("propertyPath"), "None propertyPath should be omitted");
697 assert!(!json.contains("category"), "None category should be omitted");
698 }
699
700 #[test]
701 fn report_metadata_serializes_all_required_fields() {
702 let metadata = ReportMetadata {
703 rules_evaluated: 0,
704 cfn_lint_version: "https://github.com/aws-cloudformation/cfn-lint@1.54.0".to_string(),
705 resource_schema_version:
706 "https://github.com/aws-cloudformation/resource-provider-enhanced-schemas@2026-08-07T18:20:13Z"
707 .to_string(),
708 resources_scanned: 0,
709 counts: Summary { fatal: 0, errors: 0, warnings: 0, informational: 0, debug: 0 },
710 suppressed: 0,
711 strict: false,
712 severity_level: Severity::Info,
713 budget_exhaustions: None,
714 };
715
716 let json = serde_json::to_value(metadata).expect("metadata should serialize");
717 assert_eq!(json["rulesEvaluated"], 0);
718 assert_eq!(json["cfnLintVersion"], "https://github.com/aws-cloudformation/cfn-lint@1.54.0");
719 assert_eq!(
720 json["resourceSchemaVersion"],
721 "https://github.com/aws-cloudformation/resource-provider-enhanced-schemas@2026-08-07T18:20:13Z"
722 );
723 assert!(json.get("budgetExhaustions").is_none());
724 }
725
726 #[test]
727 fn budget_exhaustion_record_serializes_description() {
728 let record = BudgetExhaustionRecord {
729 kind: "resolverDepth".to_string(),
730 description: "Resolving intrinsic functions exceeded the maximum supported nesting depth.".to_string(),
731 limit: 64,
732 analysis_incomplete: true,
733 };
734
735 let json = serde_json::to_value(record).expect("budget exhaustion record should serialize");
736 assert_eq!(json["kind"], "resolverDepth");
737 assert_eq!(json["description"], "Resolving intrinsic functions exceeded the maximum supported nesting depth.");
738 }
739
740 #[test]
741 fn budget_exhaustion_record_accepts_legacy_input_without_description() {
742 let record: BudgetExhaustionRecord = serde_json::from_value(serde_json::json!({
743 "kind": "resolverDepth",
744 "limit": 64,
745 "analysisIncomplete": true
746 }))
747 .expect("legacy budget exhaustion record should deserialize");
748
749 assert!(record.description.is_empty());
750 }
751
752 #[test]
753 fn report_status_serializes_as_screaming_snake_case() {
754 assert_eq!(serde_json::to_string(&ReportStatus::Ok).unwrap(), "\"OK\"");
755 assert_eq!(serde_json::to_string(&ReportStatus::AnalysisIncomplete).unwrap(), "\"ANALYSIS_INCOMPLETE\"");
756 assert_eq!(serde_json::to_string(&ReportStatus::Error).unwrap(), "\"ERROR\"");
757 }
758
759 #[test]
760 fn report_metadata_rejects_missing_required_fields() {
761 let complete = serde_json::json!({
762 "rulesEvaluated": 0,
763 "cfnLintVersion": "v",
764 "resourceSchemaVersion": "v",
765 "resourcesScanned": 0,
766 "counts": {
767 "fatal": 0,
768 "errors": 0,
769 "warnings": 0,
770 "informational": 0,
771 "debug": 0
772 },
773 "suppressed": 0,
774 "strict": false,
775 "severityLevel": "INFO"
776 });
777
778 for required_field in ["rulesEvaluated", "cfnLintVersion", "resourceSchemaVersion"] {
779 let mut incomplete = complete.clone();
780 incomplete.as_object_mut().unwrap().remove(required_field);
781 let error = serde_json::from_value::<ReportMetadata>(incomplete).unwrap_err();
782 assert!(
783 error.to_string().contains(required_field),
784 "missing field error must name {required_field}: {error}"
785 );
786 }
787 }
788
789 #[test]
790 fn report_metadata_budget_exhaustions_defaults_to_none() {
791 let minimal = serde_json::json!({
793 "rulesEvaluated": 0,
794 "cfnLintVersion": "v",
795 "resourceSchemaVersion": "v",
796 "resourcesScanned": 0,
797 "counts": {
798 "fatal": 0,
799 "errors": 0,
800 "warnings": 0,
801 "informational": 0,
802 "debug": 0
803 },
804 "suppressed": 0,
805 "strict": false,
806 "severityLevel": "INFO"
807 });
808 let metadata: ReportMetadata = serde_json::from_value(minimal).unwrap();
809 assert!(metadata.budget_exhaustions.is_none(), "missing metadata should deserialize to None");
810 }
811}