use crate::diagnostic::{Diagnostic, Entity, RelatedResource};
use crate::phase::Phase;
use rules::lookup_rule;
use std::collections::HashMap;
use template_model::{ParseDefect, SourceSpan, span_to_option};
#[must_use = "RegisteredDiagnostic does nothing until `build` is called"]
pub struct RegisteredDiagnostic {
rule_id: String,
message: String,
entity: Option<Entity>,
property_path: Option<String>,
location: Option<SourceSpan>,
suggested_fix: Option<String>,
condition_scenario: Option<HashMap<String, bool>>,
phase: Option<Phase>,
related_resources: Option<Vec<RelatedResource>>,
}
impl RegisteredDiagnostic {
pub fn new(rule_id: impl Into<String>, message: impl Into<String>) -> Self {
Self {
rule_id: rule_id.into(),
message: message.into(),
entity: None,
property_path: None,
location: None,
suggested_fix: None,
condition_scenario: None,
phase: None,
related_resources: None,
}
}
pub fn resource(mut self, resource_id: impl Into<String>, resource_type: Option<String>) -> Self {
self.entity = Entity::resource(resource_id, resource_type);
self
}
pub fn property_path(mut self, property_path: impl Into<String>) -> Self {
let path = property_path.into();
if !path.is_empty() {
self.property_path = Some(path);
}
self
}
pub fn location(mut self, span: SourceSpan) -> Self {
self.location = span_to_option(span);
self
}
pub fn suggested_fix(mut self, suggested_fix: Option<impl Into<String>>) -> Self {
self.suggested_fix = suggested_fix.map(Into::into);
self
}
pub fn condition_scenario(mut self, condition_scenario: Option<HashMap<String, bool>>) -> Self {
self.condition_scenario = condition_scenario;
self
}
pub fn phase(mut self, phase: Phase) -> Self {
self.phase = Some(phase);
self
}
pub fn related_resources(mut self, related_resources: Option<Vec<RelatedResource>>) -> Self {
self.related_resources = related_resources;
self
}
pub fn build(self) -> Diagnostic {
let definition =
lookup_rule(&self.rule_id).unwrap_or_else(|| panic!("Rule '{}' not found in RULE_REGISTRY", self.rule_id));
Diagnostic {
rule_id: self.rule_id,
severity: definition.severity(),
message: self.message,
entity: self.entity,
property_path: self.property_path,
suggested_fix: self.suggested_fix,
documentation_url: None,
category: Some(definition.category.as_str().into()),
location: self.location,
related_resources: self.related_resources,
condition_scenario: self.condition_scenario,
rule_description: Some(definition.description.into()),
phase: self.phase,
context: None,
source: definition.origin,
}
}
}
pub fn diagnostic_from_parse_defect(defect: &ParseDefect) -> Diagnostic {
let mut builder = RegisteredDiagnostic::new(defect.rule_id.clone(), defect.message.clone()).location(defect.span);
if let Some(resource_id) = &defect.resource_id {
builder = builder.resource(resource_id.clone(), None);
}
if let Some(property_path) = &defect.property_path {
builder = builder.property_path(property_path.clone());
}
if let Some(phase) = defect.phase {
builder = builder.phase(phase.into());
}
builder.build()
}
#[cfg(test)]
mod tests {
use super::*;
use rules::Severity;
use template_model::{DefectPhase, UNKNOWN_SPAN};
#[test]
fn build_sources_severity_category_origin_and_description_from_registry() {
let definition = lookup_rule("E3012").expect("E3012 is registered");
let diagnostic = RegisteredDiagnostic::new("E3012", "Type mismatch").build();
assert_eq!(diagnostic.rule_id, "E3012");
assert_eq!(diagnostic.severity, Severity::Error, "severity derived from the E prefix via the registry");
assert_eq!(
diagnostic.category.as_deref(),
Some(definition.category.as_str()),
"category taken from the registry entry"
);
assert_eq!(diagnostic.source, definition.origin, "origin taken from the registry entry");
assert!(diagnostic.rule_description.is_some(), "description taken from the registry entry");
}
#[test]
fn fatal_severity_is_derived_for_f_prefixed_rules() {
let diagnostic = RegisteredDiagnostic::new("F0001", "Resources section missing").build();
assert_eq!(diagnostic.severity, Severity::Fatal);
}
#[test]
fn resource_and_property_path_are_attached_when_present() {
let diagnostic = RegisteredDiagnostic::new("E3012", "msg")
.resource("MyBucket", Some("AWS::S3::Bucket".into()))
.property_path("Properties.BucketName")
.build();
let entity = diagnostic.entity.expect("entity should be set");
assert_eq!(entity.logical_id, "MyBucket");
assert_eq!(entity.entity_type, template_model::EntityType::Resource);
assert_eq!(entity.resource_type.as_deref(), Some("AWS::S3::Bucket"));
assert_eq!(diagnostic.property_path.as_deref(), Some("Properties.BucketName"));
}
#[test]
fn empty_resource_id_yields_no_entity() {
let diagnostic = RegisteredDiagnostic::new("F0001", "msg").resource("", None).build();
assert!(diagnostic.entity.is_none(), "an empty resource ID must not create an entity");
}
#[test]
fn empty_property_path_is_dropped() {
let diagnostic = RegisteredDiagnostic::new("F0001", "msg").property_path("").build();
assert_eq!(diagnostic.property_path, None, "an empty property path must not be recorded");
}
#[test]
fn unknown_span_becomes_no_location() {
let diagnostic = RegisteredDiagnostic::new("F0001", "msg").location(UNKNOWN_SPAN).build();
assert_eq!(diagnostic.location, None, "UNKNOWN_SPAN must map to no location");
}
#[test]
fn known_span_is_preserved() {
let span = SourceSpan { start_line: 3, start_column: 1, end_line: 3, end_column: 9 };
let diagnostic = RegisteredDiagnostic::new("F0001", "msg").location(span).build();
assert_eq!(diagnostic.location, Some(span));
}
#[test]
fn phase_is_pinned_when_set() {
let diagnostic = RegisteredDiagnostic::new("F0001", "msg").phase(Phase::Parse).build();
assert_eq!(diagnostic.phase, Some(Phase::Parse));
}
#[test]
#[should_panic(expected = "not found in RULE_REGISTRY")]
fn build_panics_for_unregistered_rule() {
let _ = RegisteredDiagnostic::new("Z9999", "unregistered").build();
}
#[test]
fn parse_defect_conversion_carries_anchors_and_registry_metadata() {
let span = SourceSpan { start_line: 7, start_column: 3, end_line: 7, end_column: 9 };
let defect = ParseDefect::new("F0001", "Resources section missing")
.location(span)
.resource("MyBucket")
.property_path("Properties.BucketName")
.phase(DefectPhase::Parse);
let diagnostic = diagnostic_from_parse_defect(&defect);
assert_eq!(diagnostic.rule_id, "F0001");
assert_eq!(diagnostic.severity, Severity::Fatal, "severity sourced from the registry");
assert_eq!(diagnostic.location, Some(span));
assert_eq!(diagnostic.entity.as_ref().map(|e| e.logical_id.as_str()), Some("MyBucket"));
assert_eq!(diagnostic.property_path.as_deref(), Some("Properties.BucketName"));
assert_eq!(diagnostic.phase, Some(Phase::Parse));
}
#[test]
fn parse_defect_conversion_leaves_unset_fields_absent() {
let defect = ParseDefect::new("F0001", "msg").location(UNKNOWN_SPAN);
let diagnostic = diagnostic_from_parse_defect(&defect);
assert_eq!(diagnostic.location, None, "UNKNOWN_SPAN must map to no location");
assert!(diagnostic.entity.is_none());
assert_eq!(diagnostic.property_path, None);
assert_eq!(diagnostic.phase, None, "an unset phase is derived downstream");
}
#[test]
fn lint_stage_defects_convert_to_the_lint_phase() {
let defect = ParseDefect::new("F3004", "cycle").phase(DefectPhase::Lint);
assert_eq!(diagnostic_from_parse_defect(&defect).phase, Some(Phase::Lint));
}
}