Skip to main content

diagnostics/
diagnostic.rs

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/// The template resource a diagnostic is attributed to, when it targets one.
24#[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    /// Logical ID of the resource as declared in the template.
30    #[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/// The named template entity a diagnostic is attributed to, when it targets
39/// one. The entity type is the singular form of the top-level template
40/// section the entity is declared in.
41#[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    /// Logical ID of the entity as declared in the template.
47    pub logical_id: String,
48    pub entity_type: EntityType,
49    /// CloudFormation resource type, when the entity is a resource whose type
50    /// is known.
51    #[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    /// An entity for a template resource. An empty logical ID yields `None` so
58    /// callers can pass through an ID that may be blank.
59    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/// Extra detail about a specific violation, present only in the detailed report.
69#[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    /// The resolved property value that triggered the violation.
75    #[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    /// The constraint the value was expected to satisfy (such as the required type or allowed pattern).
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
82    pub expected_constraint: Option<String>,
83    /// Name of the offending property.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
86    pub property: Option<String>,
87    /// Lifecycle marker for the flagged resource type or property, such as 'deprecated', 'create-only', or 'write-only'.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
90    pub lifecycle: Option<String>,
91    /// How the offending value was derived, such as a Ref, Fn::GetAtt, Fn::If, or parameter.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
94    pub resolution_source: Option<String>,
95    /// Additional finding-specific values keyed by name.
96    #[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/// Another resource involved in the diagnostic, such as the target of a reference.
103#[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    /// Source location of the related resource.
112    #[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    /// The named template entity this finding targets, if any.
126    #[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    /// Logical ID of the targeted entity when it is a resource, `None` otherwise.
152    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
178/// Generates a report diagnostic struct that carries the targeted entity as a
179/// nested `entity` struct and inlines `location` into individual line/column
180/// fields. Used by `StandardDiagnostic` and `DetailedDiagnostic`.
181macro_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            /// Identifier of the rule that produced this finding; its leading letter encodes the severity.
190            pub rule_id: String,
191            pub severity: Severity,
192            pub message: String,
193            /// Where the rule came from, such as a provider schema, the built-in engine, or a user-supplied rule.
194            pub source: RuleOrigin,
195            /// The named template entity this finding targets - a resource, parameter, output, mapping, condition, or template rule - if any.
196            #[serde(default, skip_serializing_if = "Option::is_none")]
197            #[cfg_attr(feature = "uniffi-bindings", uniffi(default))]
198            pub entity: Option<Entity>,
199            /// Path to the offending property within the resource, such as 'Properties.Name'.
200            #[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            /// Line in the source template where the finding begins (1-based).
210            #[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            /// Condition name to boolean assignment under which this finding applies, when it depends on template conditions.
226            #[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    /// A single validation finding with its source location flattened into individual fields.
242    StandardDiagnostic
243);
244define_flattened_diagnostic!(
245    /// A validation finding with additional context and enrichment beyond the standard finding.
246    DetailedDiagnostic,
247    documentation_url: Option<String>,
248    rule_description: Option<String>,
249    phase: Option<Phase>,
250    context: Option<ViolationContext>
251);
252
253/// Populates the shared fields of a report diagnostic from a `Diagnostic`.
254macro_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/// Timing breakdown of the validation run, per pipeline phase.
361#[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    /// Time to load the provider schemas.
367    pub schema_init: PhaseMetric,
368    /// Time to initialize the rule evaluation engine.
369    pub engine_init: PhaseMetric,
370    /// Time to parse the template and build its model.
371    pub model_build: PhaseMetric,
372    pub schema_validate: PhaseMetric,
373    pub rule_evaluation: PhaseMetric,
374    /// Time spent enriching, filtering, sorting, and finalizing the diagnostics after rule evaluation.
375    pub diagnostic_finalize: PhaseMetric,
376    pub validate_total: PhaseMetric,
377}
378
379/// A single budget-exhaustion record in report metadata.
380#[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    /// Stable lower camelCase budget kind identifier.
386    pub kind: String,
387    /// Human-readable explanation of the exhausted budget.
388    #[serde(default)]
389    pub description: String,
390    /// The numeric limit that was exhausted.
391    pub limit: u64,
392    /// Whether exhausting this budget makes the overall analysis incomplete.
393    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    /// Number of rules that were active for this run after any category exclusions.
406    pub rules_evaluated: u32,
407    /// Source-qualified cfn-lint version used to sync bundled derived data.
408    pub cfn_lint_version: String,
409    /// Source-qualified enhanced resource-schema version used for the bundled provider schemas.
410    pub resource_schema_version: String,
411    pub resources_scanned: u32,
412    /// Tally of reported diagnostics by severity.
413    pub counts: Summary,
414    /// Number of diagnostics removed by filters and the severity threshold.
415    pub suppressed: u32,
416    /// Whether strict mode was enabled, promoting warnings to errors.
417    pub strict: bool,
418    /// Minimum severity included in the report; lower-severity findings are omitted.
419    pub severity_level: Severity,
420    /// Records of deterministic validation budgets exhausted during this run.
421    /// Absent when no budget was exhausted.
422    #[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/// Outcome of a validation run. `Ok` means validation completed without
439/// correctness-affecting curtailment. `AnalysisIncomplete` means a deterministic
440/// budget curtailed analysis in a way that could omit findings. `Error` means
441/// the validation pipeline could not run, such as when parsing fails.
442#[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/// Standard validation result: the report plus flattened diagnostics.
489#[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/// Detailed validation result: like the standard report but with per-diagnostic context and enrichment.
503#[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(&parameter).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        // Older reports without budget exhaustion metadata should still deserialize.
792        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}