Skip to main content

template_model/
defect.rs

1//! Parse-time findings emitted while building the semantic model.
2//!
3//! A [`ParseDefect`] is plain data: a rule ID, a message, and locating anchors.
4//! It deliberately carries no severity, category, or origin - those are rule
5//! metadata that downstream reporting layers source from the rule registry when
6//! they convert defects into full diagnostics. This keeps the template model a
7//! leaf crate with no knowledge of the reporting vocabulary.
8
9use crate::consts;
10use crate::span::{SourceSpan, UNKNOWN_SPAN};
11
12/// The pipeline stage a defect belongs to, for downstream phase attribution.
13///
14/// Most defects are found while parsing and resolving the template
15/// ([`DefectPhase::Parse`]). Findings produced by whole-template analysis over
16/// the finished model - such as reference-cycle detection - belong to the lint
17/// stage ([`DefectPhase::Lint`]). A defect may also leave the phase unset, in
18/// which case downstream enrichment derives it from the rule's severity.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum DefectPhase {
21    Parse,
22    Lint,
23}
24
25/// A finding produced while parsing a template and building the semantic model.
26///
27/// Field semantics mirror the diagnostic they are converted into downstream:
28/// `span` is [`UNKNOWN_SPAN`] when no source location is known, and the
29/// optional anchors attribute the defect to a named template entity.
30#[derive(Debug, Clone, PartialEq)]
31pub struct ParseDefect {
32    /// Registry rule ID of the check this defect belongs to.
33    pub rule_id: String,
34    pub message: String,
35    /// Source location, or [`UNKNOWN_SPAN`] when none has been resolved yet.
36    pub span: SourceSpan,
37    /// Logical ID of the resource (or output pseudo-resource) the defect is
38    /// anchored to, when it targets one.
39    pub resource_id: Option<String>,
40    /// Slash- or dot-separated path locating the defect within the template.
41    pub property_path: Option<String>,
42    /// Pipeline stage attribution; `None` lets downstream enrichment derive it.
43    pub phase: Option<DefectPhase>,
44}
45
46impl ParseDefect {
47    /// Starts a defect for `rule_id` carrying `message`. All anchors default to
48    /// absent and are added through the builder-style methods below.
49    pub fn new(rule_id: impl Into<String>, message: impl Into<String>) -> Self {
50        Self {
51            rule_id: rule_id.into(),
52            message: message.into(),
53            span: UNKNOWN_SPAN,
54            resource_id: None,
55            property_path: None,
56            phase: None,
57        }
58    }
59
60    /// Sets the source span.
61    #[must_use]
62    pub fn location(mut self, span: SourceSpan) -> Self {
63        self.span = span;
64        self
65    }
66
67    /// Anchors the defect to a resource logical ID. An empty ID is dropped so
68    /// callers can pass through an ID that may be blank.
69    #[must_use]
70    pub fn resource(mut self, resource_id: impl Into<String>) -> Self {
71        let id = resource_id.into();
72        if !id.is_empty() {
73            self.resource_id = Some(id);
74        }
75        self
76    }
77
78    /// Records the property path. An empty path is ignored so callers can pass
79    /// through a path that may be blank.
80    #[must_use]
81    pub fn property_path(mut self, property_path: impl Into<String>) -> Self {
82        let path = property_path.into();
83        if !path.is_empty() {
84            self.property_path = Some(path);
85        }
86        self
87    }
88
89    /// Pins the pipeline stage the defect belongs to.
90    #[must_use]
91    pub fn phase(mut self, phase: DefectPhase) -> Self {
92        self.phase = Some(phase);
93        self
94    }
95
96    /// Whether this defect represents a fatal structural failure. Built-in rule
97    /// IDs encode severity in their prefix letter, and this crate assigns every
98    /// parse rule ID, so the prefix is authoritative here.
99    #[must_use]
100    pub fn is_fatal(&self) -> bool {
101        self.rule_id.starts_with('F')
102    }
103
104    /// Logical ID of the resource the defect is anchored to, when it targets one.
105    #[must_use]
106    pub fn resource_logical_id(&self) -> Option<&str> {
107        self.resource_id.as_deref()
108    }
109}
110
111/// A parse-time defect anchored to a specific resource (or output pseudo-
112/// resource), so consumers and the accuracy comparison can key it by resource id
113/// the same way an engine-emitted resource diagnostic would.
114pub(crate) fn make_parse_defect_for_resource(
115    rule_id: &str,
116    message: String,
117    span: SourceSpan,
118    resource_id: &str,
119) -> ParseDefect {
120    ParseDefect::new(rule_id, message).location(span).resource(resource_id).phase(DefectPhase::Parse)
121}
122
123pub(crate) fn make_parse_defect(rule_id: &str, message: String, span: SourceSpan) -> ParseDefect {
124    ParseDefect::new(rule_id, message).location(span).phase(DefectPhase::Parse)
125}
126
127/// Like [`make_parse_defect`], but attaches a locating anchor derived from a
128/// builder path such as `Resources/R/Properties/X/Fn::If` or
129/// `Conditions/C/Fn::And`. A resource-property defect carries the logical ID and a
130/// dotted property path so it lands where consumers expect. Defects in other
131/// sections (`Conditions`, `Outputs`, …) carry the build path itself as the
132/// property path, so that when the exact node has no byte span yet, downstream
133/// span resolution can walk up to the nearest enclosing element (the named
134/// condition/output) instead of leaving the defect unlocated.
135pub(crate) fn make_parse_defect_at(rule_id: &str, message: String, span: SourceSpan, build_path: &str) -> ParseDefect {
136    let mut defect = ParseDefect::new(rule_id, message).location(span).phase(DefectPhase::Parse);
137    let segments: Vec<&str> = build_path.split('/').collect();
138    let is_resource_value = segments.len() >= 4
139        && segments[0] == consts::SECTION_RESOURCES
140        && matches!(segments[2], consts::KEY_PROPERTIES | consts::SECTION_METADATA);
141    let is_lifecycle_attribute = segments.len() >= 3
142        && segments[0] == consts::SECTION_RESOURCES
143        && matches!(
144            segments[2],
145            consts::KEY_CREATION_POLICY
146                | consts::KEY_UPDATE_POLICY
147                | consts::KEY_DELETION_POLICY
148                | consts::KEY_UPDATE_REPLACE_POLICY
149        );
150    if is_resource_value || is_lifecycle_attribute {
151        defect = defect.resource(segments[1]);
152        defect = defect.property_path(segments[2..].join("."));
153    } else if segments.len() >= 2 {
154        // Non-resource section (e.g. Conditions/<name>/Fn::And): keep the full
155        // slash path so span resolution can walk up to the enclosing element.
156        defect = defect.property_path(build_path);
157    }
158    defect
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn empty_resource_id_and_property_path_are_dropped() {
167        let defect = ParseDefect::new("F0001", "msg").resource("").property_path("");
168        assert_eq!(defect.resource_id, None, "an empty resource ID must not create an anchor");
169        assert_eq!(defect.property_path, None, "an empty property path must not be recorded");
170    }
171
172    #[test]
173    fn is_fatal_follows_the_rule_id_prefix() {
174        assert!(ParseDefect::new("F8600", "msg").is_fatal());
175        assert!(!ParseDefect::new("E0001", "msg").is_fatal());
176        assert!(!ParseDefect::new("W1103", "msg").is_fatal());
177    }
178
179    #[test]
180    fn make_parse_defect_at_splits_resource_property_paths() {
181        let defect = make_parse_defect_at("F1032", "msg".into(), UNKNOWN_SPAN, "Resources/R/Properties/X/Fn::If");
182        assert_eq!(defect.resource_id.as_deref(), Some("R"));
183        assert_eq!(defect.property_path.as_deref(), Some("Properties.X.Fn::If"));
184        assert_eq!(defect.phase, Some(DefectPhase::Parse));
185    }
186
187    #[test]
188    fn make_parse_defect_at_splits_lifecycle_attribute_paths() {
189        let defect = make_parse_defect_at("F1101", "msg".into(), UNKNOWN_SPAN, "Resources/R/DeletionPolicy/Fn::If/1");
190        assert_eq!(defect.resource_id.as_deref(), Some("R"));
191        assert_eq!(defect.property_path.as_deref(), Some("DeletionPolicy.Fn::If.1"));
192        assert_eq!(defect.phase, Some(DefectPhase::Parse));
193    }
194
195    #[test]
196    fn make_parse_defect_at_keeps_non_resource_paths_verbatim() {
197        let defect = make_parse_defect_at("E8005", "msg".into(), UNKNOWN_SPAN, "Conditions/C/Fn::And");
198        assert_eq!(defect.resource_id, None);
199        assert_eq!(defect.property_path.as_deref(), Some("Conditions/C/Fn::And"));
200    }
201}