1use crate::detail_level::DetailLevel;
2use crate::filter::Filterable;
3use crate::metrics::PhaseMetric;
4use crate::output;
5use crate::phase::Phase;
6use rules::{RuleOrigin, Severity};
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashMap};
9use template_model::{EntityType, JsonValue, SourceSpan};
10
11pub(crate) fn serialize_sorted_optional_map<S, V>(
12 map: &Option<HashMap<String, V>>,
13 serializer: S,
14) -> Result<S::Ok, S::Error>
15where
16 S: serde::Serializer,
17 V: Serialize,
18{
19 match map {
20 Some(m) => {
21 let sorted: BTreeMap<&String, &V> = m.iter().collect();
22 sorted.serialize(serializer)
23 }
24 None => serializer.serialize_none(),
25 }
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
31#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
32#[serde(rename_all = "camelCase")]
33pub struct ResourceRef {
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
37 pub id: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
40 pub resource_type: Option<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
48#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
49#[serde(rename_all = "camelCase")]
50pub struct Entity {
51 pub logical_id: String,
53 pub entity_type: EntityType,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
57 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
58 pub resource_type: Option<String>,
59}
60
61impl Entity {
62 pub fn resource(logical_id: impl Into<String>, resource_type: Option<String>) -> Option<Entity> {
65 let logical_id = logical_id.into();
66 if logical_id.is_empty() {
67 return None;
68 }
69 Some(Entity { logical_id, entity_type: EntityType::Resource, resource_type })
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
76#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
77#[serde(rename_all = "camelCase")]
78pub struct ViolationContext {
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 #[cfg_attr(feature = "wasm-bindings", tsify(type = "JsonValue"))]
82 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
83 pub actual_value: Option<JsonValue>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
87 pub expected_constraint: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
91 pub property: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
95 pub lifecycle: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
99 pub resolution_source: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "serialize_sorted_optional_map")]
102 #[cfg_attr(feature = "wasm-bindings", tsify(type = "Record<string, JsonValue>"))]
103 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
104 pub extra: Option<HashMap<String, JsonValue>>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
110#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
111#[serde(rename_all = "camelCase")]
112pub struct RelatedResource {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
115 pub resource: Option<ResourceRef>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
119 pub location: Option<SourceSpan>,
120 pub message: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct Diagnostic {
126 pub rule_id: String,
127 pub severity: Severity,
128 pub message: String,
129 pub source: RuleOrigin,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub entity: Option<Entity>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub property_path: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub suggested_fix: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub documentation_url: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub category: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub location: Option<SourceSpan>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub related_resources: Option<Vec<RelatedResource>>,
145 #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "serialize_sorted_optional_map")]
146 pub condition_scenario: Option<HashMap<String, bool>>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub rule_description: Option<String>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub phase: Option<Phase>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub context: Option<ViolationContext>,
153}
154
155impl Diagnostic {
156 pub fn resource_logical_id(&self) -> Option<&str> {
158 self.entity.as_ref().filter(|e| e.entity_type == EntityType::Resource).map(|e| e.logical_id.as_str())
159 }
160}
161
162impl Filterable for Diagnostic {
163 fn rule_id(&self) -> &str {
164 &self.rule_id
165 }
166 fn category(&self) -> Option<&str> {
167 self.category.as_deref()
168 }
169 fn resource_id(&self) -> Option<&str> {
170 self.resource_logical_id()
171 }
172 fn resource_type(&self) -> Option<&str> {
173 self.entity.as_ref().and_then(|e| e.resource_type.as_deref())
174 }
175 fn logical_id(&self) -> Option<&str> {
176 self.entity.as_ref().map(|e| e.logical_id.as_str())
177 }
178 fn entity_type(&self) -> Option<EntityType> {
179 self.entity.as_ref().map(|e| e.entity_type)
180 }
181}
182
183impl Diagnostic {
184 pub fn to_report(&self, detail_level: DetailLevel) -> output::Diagnostic {
189 let (start_line, start_column, end_line, end_column) = self
190 .location
191 .map(|span| (Some(span.start_line), Some(span.start_column), Some(span.end_line), Some(span.end_column)))
192 .unwrap_or((None, None, None, None));
193 let (documentation_url, rule_description, phase, context) = match detail_level {
194 DetailLevel::Detailed => {
195 (self.documentation_url.clone(), self.rule_description.clone(), self.phase, self.context.clone())
196 }
197 DetailLevel::Standard => (None, None, None, None),
198 };
199 output::Diagnostic {
200 rule_id: self.rule_id.clone(),
201 severity: self.severity,
202 message: self.message.clone(),
203 source: self.source,
204 entity: self.entity.clone(),
205 property_path: self.property_path.clone(),
206 suggested_fix: self.suggested_fix.clone(),
207 category: self.category.clone(),
208 start_line,
209 start_column,
210 end_line,
211 end_column,
212 related_resources: self.related_resources.clone(),
213 condition_scenario: self.condition_scenario.clone(),
214 documentation_url,
215 rule_description,
216 phase,
217 context,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
225#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
226#[serde(rename_all = "camelCase")]
227pub struct PerformanceMetrics {
228 pub schema_init: PhaseMetric,
230 pub engine_init: PhaseMetric,
232 pub model_build: PhaseMetric,
234 pub schema_validate: PhaseMetric,
235 pub rule_evaluation: PhaseMetric,
236 pub diagnostic_finalize: PhaseMetric,
238 pub validate_total: PhaseMetric,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
243#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
244#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
245#[serde(rename_all = "camelCase")]
246pub struct BudgetExhaustionRecord {
247 pub kind: String,
249 #[serde(default)]
251 pub description: String,
252 pub limit: u64,
254 pub analysis_incomplete: bool,
256}
257
258fn budget_exhaustions_are_absent_or_empty(records: &Option<Vec<BudgetExhaustionRecord>>) -> bool {
259 records.as_ref().is_none_or(Vec::is_empty)
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize)]
263#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
264#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
265#[serde(rename_all = "camelCase")]
266pub struct ReportMetadata {
267 pub rules_evaluated: u32,
269 pub cfn_lint_version: String,
271 pub resource_schema_version: String,
273 pub resources_scanned: u32,
274 pub counts: Summary,
276 pub suppressed: u32,
278 pub strict: bool,
280 pub severity_level: Severity,
282 #[serde(default, skip_serializing_if = "budget_exhaustions_are_absent_or_empty")]
285 pub budget_exhaustions: Option<Vec<BudgetExhaustionRecord>>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
290#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))]
291#[serde(rename_all = "camelCase")]
292pub struct Summary {
293 pub fatal: u32,
294 pub errors: u32,
295 pub warnings: u32,
296 pub informational: u32,
297 pub debug: u32,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[cfg_attr(feature = "wasm-bindings", derive(tsify::Tsify))]
306#[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Enum))]
307#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
308pub enum ReportStatus {
309 Ok,
310 AnalysisIncomplete,
311 Error,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315#[serde(rename_all = "camelCase")]
316#[must_use]
317pub struct ValidationReport {
318 pub file_path: String,
319 pub status: ReportStatus,
320 pub version: String,
321 pub metadata: ReportMetadata,
322 pub performance: PerformanceMetrics,
323 pub diagnostics: Vec<Diagnostic>,
324}
325
326impl ValidationReport {
327 pub fn to_report(&self, detail_level: DetailLevel) -> output::ValidationReport {
330 output::ValidationReport {
331 file_path: self.file_path.clone(),
332 status: self.status,
333 version: self.version.clone(),
334 diagnostics: self.diagnostics.iter().map(|d| d.to_report(detail_level.clone())).collect(),
335 metadata: self.metadata.clone(),
336 performance: self.performance.clone(),
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn sample_diagnostic() -> Diagnostic {
346 Diagnostic {
347 rule_id: "E3012".into(),
348 severity: Severity::Error,
349 message: "Property not allowed".into(),
350 entity: Entity::resource("MyBucket", Some("AWS::S3::Bucket".into())),
351 property_path: Some("/Resources/MyBucket/Properties/Foo".into()),
352 suggested_fix: Some("Remove the property".into()),
353 documentation_url: Some("https://example.com/E3012".into()),
354 category: Some("schema".into()),
355 location: Some(SourceSpan { start_line: 10, start_column: 5, end_line: 10, end_column: 20 }),
356 related_resources: Some(vec![RelatedResource {
357 resource: Some(ResourceRef {
358 id: Some("OtherResource".into()),
359 resource_type: Some("AWS::EC2::Instance".into()),
360 }),
361 location: Some(SourceSpan { start_line: 20, start_column: 1, end_line: 20, end_column: 30 }),
362 message: "Referenced here".into(),
363 }]),
364 condition_scenario: Some(HashMap::from([("IsProduction".into(), true)])),
365 rule_description: Some("Disallows extra properties".into()),
366 phase: Some(Phase::Schema),
367 context: Some(ViolationContext {
368 actual_value: Some(JsonValue::from(serde_json::json!("bad"))),
369 expected_constraint: Some("Must not exist".into()),
370 property: Some("Foo".into()),
371 lifecycle: None,
372 resolution_source: None,
373 extra: None,
374 }),
375 source: RuleOrigin::CfnLint,
376 }
377 }
378
379 fn minimal_diagnostic() -> Diagnostic {
380 Diagnostic {
381 rule_id: String::new(),
382 severity: Severity::Info,
383 message: String::new(),
384 entity: None,
385 property_path: None,
386 suggested_fix: None,
387 documentation_url: None,
388 category: None,
389 location: None,
390 related_resources: None,
391 condition_scenario: None,
392 rule_description: None,
393 phase: None,
394 context: None,
395 source: RuleOrigin::Engine,
396 }
397 }
398
399 #[test]
400 fn standard_projection_carries_entity_flattens_location_and_drops_enrichment() {
401 let d = sample_diagnostic();
402 let s = d.to_report(DetailLevel::Standard);
403
404 assert_eq!(s.rule_id, "E3012");
405 let entity = s.entity.as_ref().expect("entity should be present");
406 assert_eq!(entity.logical_id, "MyBucket");
407 assert_eq!(entity.entity_type, EntityType::Resource);
408 assert_eq!(entity.resource_type.as_deref(), Some("AWS::S3::Bucket"));
409 assert_eq!(s.start_line, Some(10));
410 assert_eq!(s.start_column, Some(5));
411 assert_eq!(s.end_line, Some(10));
412 assert_eq!(s.end_column, Some(20));
413 assert_eq!(s.message, "Property not allowed");
414 assert_eq!(s.category.as_deref(), Some("schema"));
415 assert_eq!(s.suggested_fix.as_deref(), Some("Remove the property"));
416 assert_eq!(s.related_resources.as_ref().unwrap().len(), 1);
417 assert_ne!(s.condition_scenario, None, "condition_scenario should be present");
418
419 assert_eq!(s.documentation_url, None, "standard projection must drop documentation_url");
420 assert_eq!(s.rule_description, None, "standard projection must drop rule_description");
421 assert_eq!(s.phase, None, "standard projection must drop phase");
422 assert!(s.context.is_none(), "standard projection must drop context");
423 }
424
425 #[test]
426 fn detailed_projection_includes_context_and_enrichment_fields() {
427 let d = sample_diagnostic();
428 let f = d.to_report(DetailLevel::Detailed);
429
430 assert_eq!(f.rule_id, "E3012");
431 assert_eq!(f.entity.as_ref().map(|e| e.logical_id.as_str()), Some("MyBucket"));
432 assert_eq!(f.documentation_url.as_deref(), Some("https://example.com/E3012"));
433 assert_eq!(f.rule_description.as_deref(), Some("Disallows extra properties"));
434 assert_eq!(f.phase, Some(Phase::Schema));
435 let ctx = f.context.as_ref().expect("detailed projection should include context");
436 assert_eq!(ctx.property.as_deref(), Some("Foo"));
437 assert_eq!(ctx.expected_constraint.as_deref(), Some("Must not exist"));
438 }
439
440 #[test]
441 fn filterable_reads_identity_through_the_entity() {
442 let d = sample_diagnostic();
443 assert_eq!(d.rule_id(), "E3012");
444 assert_eq!(d.category(), Some("schema"));
445 assert_eq!(d.resource_id(), Some("MyBucket"));
446 assert_eq!(d.resource_type(), Some("AWS::S3::Bucket"));
447 assert_eq!(d.logical_id(), Some("MyBucket"));
448 }
449
450 #[test]
451 fn filterable_resource_id_is_none_for_non_resource_entities() {
452 let mut d = sample_diagnostic();
453 d.entity =
454 Some(Entity { logical_id: "MyParam".into(), entity_type: EntityType::Parameter, resource_type: None });
455 assert_eq!(d.resource_id(), None, "a parameter is not a resource");
456 assert_eq!(d.resource_type(), None);
457 assert_eq!(d.logical_id(), Some("MyParam"));
458 }
459
460 #[test]
461 fn entity_serializes_camel_case_with_pascal_case_type_and_omits_absent_resource_type() {
462 let resource = Entity::resource("MyBucket", Some("AWS::S3::Bucket".into())).unwrap();
463 let json = serde_json::to_string(&resource).unwrap();
464 assert!(json.contains("\"logicalId\":\"MyBucket\""), "got: {json}");
465 assert!(json.contains("\"entityType\":\"Resource\""), "got: {json}");
466 assert!(json.contains("\"resourceType\":\"AWS::S3::Bucket\""), "got: {json}");
467
468 let parameter =
469 Entity { logical_id: "MyParam".into(), entity_type: EntityType::Parameter, resource_type: None };
470 let json = serde_json::to_string(¶meter).unwrap();
471 assert!(json.contains("\"entityType\":\"Parameter\""), "got: {json}");
472 assert!(!json.contains("resourceType"), "absent resourceType must be omitted, got: {json}");
473 }
474
475 #[test]
476 fn entity_resource_drops_empty_logical_id() {
477 assert!(Entity::resource("", None).is_none(), "an empty logical ID must not create an entity");
478 }
479
480 #[test]
481 fn diagnostic_serde_round_trips_all_fields() {
482 let d = sample_diagnostic();
483 let json = serde_json::to_string(&d).unwrap();
484 let deserialized: Diagnostic = serde_json::from_str(&json).unwrap();
485 assert_eq!(deserialized.rule_id, d.rule_id);
486 assert_eq!(deserialized.message, d.message);
487 assert_eq!(deserialized.severity, d.severity);
488 assert_eq!(deserialized.source, d.source);
489 assert_eq!(deserialized.entity.as_ref().map(|e| e.logical_id.as_str()), Some("MyBucket"));
490 assert_eq!(deserialized.location.as_ref().unwrap().start_line, d.location.as_ref().unwrap().start_line);
491 }
492
493 #[test]
494 fn standard_projection_uses_camel_case_and_omits_enrichment_in_json() {
495 let d = sample_diagnostic();
496 let s = d.to_report(DetailLevel::Standard);
497 let json = serde_json::to_string(&s).unwrap();
498 assert!(json.contains("ruleId"), "expected camelCase 'ruleId' in JSON");
499 assert!(json.contains("startLine"), "expected 'startLine' in JSON");
500 assert!(json.contains("\"entity\""), "expected nested 'entity' in JSON");
501 assert!(json.contains("logicalId"), "expected 'logicalId' in JSON");
502 assert!(json.contains("entityType"), "expected 'entityType' in JSON");
503 assert!(json.contains("resourceType"), "expected 'resourceType' in JSON");
504 assert!(json.contains("propertyPath"), "expected 'propertyPath' in JSON");
505 assert!(!json.contains("\"context\""), "standard projection must omit 'context'");
506 assert!(!json.contains("documentationUrl"), "standard projection must omit 'documentationUrl'");
507 assert!(!json.contains("ruleDescription"), "standard projection must omit 'ruleDescription'");
508 assert!(!json.contains("\"phase\""), "standard projection must omit 'phase'");
509 }
510
511 #[test]
512 fn detailed_projection_includes_enrichment_in_serialization() {
513 let d = sample_diagnostic();
514 let f = d.to_report(DetailLevel::Detailed);
515 let json = serde_json::to_string(&f).unwrap();
516 assert!(json.contains("\"context\""), "detailed projection should include 'context'");
517 assert!(json.contains("actualValue"), "detailed projection should include 'actualValue'");
518 assert!(json.contains("expectedConstraint"), "detailed projection should include 'expectedConstraint'");
519 assert!(json.contains("documentationUrl"), "detailed projection should include 'documentationUrl'");
520 assert!(json.contains("ruleDescription"), "detailed projection should include 'ruleDescription'");
521 assert!(json.contains("\"phase\""), "detailed projection should include 'phase'");
522 }
523
524 #[test]
525 fn standard_projection_is_detailed_projection_without_enrichment_fields() {
526 let d = sample_diagnostic();
527 let standard = serde_json::to_value(d.to_report(DetailLevel::Standard)).unwrap();
528 let mut detailed = serde_json::to_value(d.to_report(DetailLevel::Detailed)).unwrap();
529
530 let detailed_fields = detailed.as_object_mut().expect("diagnostic serializes as an object");
531 for enrichment_field in ["documentationUrl", "ruleDescription", "phase", "context"] {
532 detailed_fields.remove(enrichment_field);
533 }
534
535 assert_eq!(standard, detailed, "standard output must equal the detailed output minus the enrichment fields");
536 }
537
538 #[test]
539 fn none_fields_are_omitted_from_serialization() {
540 let d = minimal_diagnostic();
541 let json = serde_json::to_string(&d).unwrap();
542 assert!(!json.contains("suggestedFix"), "None suggestedFix should be omitted");
543 assert!(!json.contains("documentationUrl"), "None documentationUrl should be omitted");
544 assert!(!json.contains("context"), "None context should be omitted");
545 assert!(!json.contains("relatedResources"), "None relatedResources should be omitted");
546 assert!(!json.contains("conditionScenario"), "None conditionScenario should be omitted");
547 assert!(!json.contains("propertyPath"), "None propertyPath should be omitted");
548 assert!(!json.contains("category"), "None category should be omitted");
549 }
550
551 #[test]
552 fn report_metadata_serializes_all_required_fields() {
553 let metadata = ReportMetadata {
554 rules_evaluated: 0,
555 cfn_lint_version: "https://github.com/aws-cloudformation/cfn-lint@1.54.0".to_string(),
556 resource_schema_version:
557 "https://github.com/aws-cloudformation/resource-provider-enhanced-schemas@2026-08-07T18:20:13Z"
558 .to_string(),
559 resources_scanned: 0,
560 counts: Summary { fatal: 0, errors: 0, warnings: 0, informational: 0, debug: 0 },
561 suppressed: 0,
562 strict: false,
563 severity_level: Severity::Info,
564 budget_exhaustions: None,
565 };
566
567 let json = serde_json::to_value(metadata).expect("metadata should serialize");
568 assert_eq!(json["rulesEvaluated"], 0);
569 assert_eq!(json["cfnLintVersion"], "https://github.com/aws-cloudformation/cfn-lint@1.54.0");
570 assert_eq!(
571 json["resourceSchemaVersion"],
572 "https://github.com/aws-cloudformation/resource-provider-enhanced-schemas@2026-08-07T18:20:13Z"
573 );
574 assert!(json.get("budgetExhaustions").is_none());
575 }
576
577 #[test]
578 fn budget_exhaustion_record_serializes_description() {
579 let record = BudgetExhaustionRecord {
580 kind: "resolverDepth".to_string(),
581 description: "Resolving intrinsic functions exceeded the maximum supported nesting depth.".to_string(),
582 limit: 64,
583 analysis_incomplete: true,
584 };
585
586 let json = serde_json::to_value(record).expect("budget exhaustion record should serialize");
587 assert_eq!(json["kind"], "resolverDepth");
588 assert_eq!(json["description"], "Resolving intrinsic functions exceeded the maximum supported nesting depth.");
589 }
590
591 #[test]
592 fn budget_exhaustion_record_accepts_legacy_input_without_description() {
593 let record: BudgetExhaustionRecord = serde_json::from_value(serde_json::json!({
594 "kind": "resolverDepth",
595 "limit": 64,
596 "analysisIncomplete": true
597 }))
598 .expect("legacy budget exhaustion record should deserialize");
599
600 assert!(record.description.is_empty());
601 }
602
603 #[test]
604 fn report_status_serializes_as_screaming_snake_case() {
605 assert_eq!(serde_json::to_string(&ReportStatus::Ok).unwrap(), "\"OK\"");
606 assert_eq!(serde_json::to_string(&ReportStatus::AnalysisIncomplete).unwrap(), "\"ANALYSIS_INCOMPLETE\"");
607 assert_eq!(serde_json::to_string(&ReportStatus::Error).unwrap(), "\"ERROR\"");
608 }
609
610 #[test]
611 fn report_metadata_rejects_missing_required_fields() {
612 let complete = serde_json::json!({
613 "rulesEvaluated": 0,
614 "cfnLintVersion": "v",
615 "resourceSchemaVersion": "v",
616 "resourcesScanned": 0,
617 "counts": {
618 "fatal": 0,
619 "errors": 0,
620 "warnings": 0,
621 "informational": 0,
622 "debug": 0
623 },
624 "suppressed": 0,
625 "strict": false,
626 "severityLevel": "INFO"
627 });
628
629 for required_field in ["rulesEvaluated", "cfnLintVersion", "resourceSchemaVersion"] {
630 let mut incomplete = complete.clone();
631 incomplete.as_object_mut().unwrap().remove(required_field);
632 let error = serde_json::from_value::<ReportMetadata>(incomplete).unwrap_err();
633 assert!(
634 error.to_string().contains(required_field),
635 "missing field error must name {required_field}: {error}"
636 );
637 }
638 }
639
640 #[test]
641 fn report_metadata_budget_exhaustions_defaults_to_none() {
642 let minimal = serde_json::json!({
644 "rulesEvaluated": 0,
645 "cfnLintVersion": "v",
646 "resourceSchemaVersion": "v",
647 "resourcesScanned": 0,
648 "counts": {
649 "fatal": 0,
650 "errors": 0,
651 "warnings": 0,
652 "informational": 0,
653 "debug": 0
654 },
655 "suppressed": 0,
656 "strict": false,
657 "severityLevel": "INFO"
658 });
659 let metadata: ReportMetadata = serde_json::from_value(minimal).unwrap();
660 assert!(metadata.budget_exhaustions.is_none(), "missing metadata should deserialize to None");
661 }
662}