Skip to main content

diagnostics/
diagnostic.rs

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/// The template resource a diagnostic is attributed to, when it targets one.
29#[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    /// Logical ID of the resource as declared in the template.
35    #[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/// The named template entity a diagnostic is attributed to, when it targets
44/// one. The entity type is the singular form of the top-level template
45/// section the entity is declared in.
46#[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    /// Logical ID of the entity as declared in the template.
52    pub logical_id: String,
53    pub entity_type: EntityType,
54    /// CloudFormation resource type, when the entity is a resource whose type
55    /// is known.
56    #[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    /// An entity for a template resource. An empty logical ID yields `None` so
63    /// callers can pass through an ID that may be blank.
64    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/// Extra detail about a specific violation, present only at the `DETAILED` detail level.
74#[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    /// The resolved property value that triggered the violation.
80    #[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    /// The constraint the value was expected to satisfy (such as the required type or allowed pattern).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
87    pub expected_constraint: Option<String>,
88    /// Name of the offending property.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
91    pub property: Option<String>,
92    /// Lifecycle marker for the flagged resource type or property, such as 'deprecated', 'create-only', or 'write-only'.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
95    pub lifecycle: Option<String>,
96    /// How the offending value was derived, such as a Ref, Fn::GetAtt, Fn::If, or parameter.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
99    pub resolution_source: Option<String>,
100    /// Additional finding-specific values keyed by name.
101    #[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/// Another resource involved in the diagnostic, such as the target of a reference.
108#[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    /// Source location of the related resource.
117    #[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    /// The named template entity this finding targets, if any.
131    #[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    /// Logical ID of the targeted entity when it is a resource, `None` otherwise.
157    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    /// Projects this diagnostic into the public flattened shape. The enrichment
185    /// fields (`documentation_url`, `rule_description`, `phase`, `context`) are
186    /// carried through only at the `DETAILED` detail level; the `STANDARD` detail
187    /// level leaves them `None` so serialization omits them.
188    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/// Timing breakdown of the validation run, per pipeline phase.
223#[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    /// Time to load the provider schemas.
229    pub schema_init: PhaseMetric,
230    /// Time to initialize the rule evaluation engine.
231    pub engine_init: PhaseMetric,
232    /// Time to parse the template and build its model.
233    pub model_build: PhaseMetric,
234    pub schema_validate: PhaseMetric,
235    pub rule_evaluation: PhaseMetric,
236    /// Time spent enriching, filtering, sorting, and finalizing the diagnostics after rule evaluation.
237    pub diagnostic_finalize: PhaseMetric,
238    pub validate_total: PhaseMetric,
239}
240
241/// A single budget-exhaustion record in report metadata.
242#[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    /// Stable lower camelCase budget kind identifier.
248    pub kind: String,
249    /// Human-readable explanation of the exhausted budget.
250    #[serde(default)]
251    pub description: String,
252    /// The numeric limit that was exhausted.
253    pub limit: u64,
254    /// Whether exhausting this budget makes the overall analysis incomplete.
255    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    /// Number of rules that were active for this run after any category exclusions.
268    pub rules_evaluated: u32,
269    /// Source-qualified cfn-lint version used to sync bundled derived data.
270    pub cfn_lint_version: String,
271    /// Source-qualified enhanced resource-schema version used for the bundled provider schemas.
272    pub resource_schema_version: String,
273    pub resources_scanned: u32,
274    /// Tally of reported diagnostics by severity.
275    pub counts: Summary,
276    /// Number of diagnostics removed by filters and the severity threshold.
277    pub suppressed: u32,
278    /// Whether strict mode was enabled, promoting warnings to errors.
279    pub strict: bool,
280    /// Minimum severity included in the report; lower-severity findings are omitted.
281    pub severity_level: Severity,
282    /// Records of deterministic validation budgets exhausted during this run.
283    /// Absent when no budget was exhausted.
284    #[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/// Outcome of a validation run. `Ok` means validation completed without
301/// correctness-affecting curtailment. `AnalysisIncomplete` means a deterministic
302/// budget curtailed analysis in a way that could omit findings. `Error` means
303/// the validation pipeline could not run, such as when parsing fails.
304#[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    /// Projects this report into its serializable shape, applying `detail_level`
328    /// to every diagnostic.
329    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(&parameter).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        // Older reports without budget exhaustion metadata should still deserialize.
643        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}