Skip to main content

tfparser_core/
diagnostic.rs

1//! Non-fatal diagnostics attached to the workspace IR.
2//!
3//! Diagnostics are how the parser reports problems without aborting the
4//! whole run. Per [70-security.md § 3.2], every configurable resource limit
5//! has a corresponding [`LimitKind`] so a breach surfaces with structured
6//! context rather than a free-form string.
7//!
8//! [70-security.md § 3.2]: ../../specs/70-security.md
9
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13use typed_builder::TypedBuilder;
14
15use crate::ir::Span;
16
17/// How serious a diagnostic is.
18///
19/// Severities are **not** error vs success — every variant is non-fatal at
20/// the workspace level. They drive UI rendering and CLI exit codes (the
21/// CLI may map `Error` severities to exit code 8 if `--fail-on-diagnostics`
22/// is set; see [50-cli.md § 4.3](../../specs/50-cli.md)).
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "kebab-case")]
25#[non_exhaustive]
26pub enum Severity {
27    /// Likely-not-actionable signal — e.g. "skipped unknown extension".
28    Trace,
29    /// Hint about something the user may want to fix.
30    Info,
31    /// Probably a problem; parse can still complete.
32    Warn,
33    /// Definite problem; the affected entity may be missing rows / fields.
34    Error,
35}
36
37/// Which configurable limit fired.
38///
39/// Mirrors every cap pinned in [70-security.md § 3.2](../../specs/70-security.md).
40/// New variants are additive.
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "kebab-case")]
43#[non_exhaustive]
44pub enum LimitKind {
45    /// Per-file byte cap exceeded (loader / discovery).
46    FileSize,
47    /// Workspace-wide file-count cap exceeded (discovery).
48    TotalFiles,
49    /// Walk-depth cap exceeded (discovery).
50    WalkDepth,
51    /// Per-file block-count cap exceeded (loader).
52    BlocksPerFile,
53    /// AST/attribute depth cap exceeded (loader).
54    AttributeDepth,
55    /// Template-concat parts cap exceeded (loader).
56    TemplateParts,
57    /// Terragrunt include-depth cap exceeded.
58    IncludeDepth,
59    /// Evaluator iteration cap exceeded.
60    EvalIterations,
61    /// Function argument-count cap exceeded.
62    FuncArgs,
63    /// Rendered list length cap exceeded.
64    ListLength,
65    /// Rendered string size cap exceeded.
66    StringSize,
67    /// `count` / `for_each` expansion cap exceeded (graph phase).
68    Expansion,
69    /// Profile map / AWS config file size cap exceeded.
70    ConfigFileSize,
71}
72
73/// A non-fatal diagnostic.
74///
75/// `code` is a stable, machine-readable identifier like `"TF1001"`; tooling
76/// uses it to allowlist / ignore specific diagnostics without matching the
77/// message string. `limit_kind` is set on diagnostics emitted in response to
78/// a configured cap breach, so consumers can pivot on the structured kind
79/// rather than parsing the message.
80#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, TypedBuilder)]
81#[non_exhaustive]
82#[serde(rename_all = "camelCase")]
83#[builder(field_defaults(setter(into)))]
84pub struct Diagnostic {
85    /// Severity.
86    pub severity: Severity,
87    /// Stable identifier (e.g. `"TF1001"`, `"TG2003"`).
88    pub code: Arc<str>,
89    /// Human-readable message; safe to log at the diagnostic's severity.
90    pub message: Arc<str>,
91    /// Optional location.
92    #[builder(default)]
93    pub span: Option<Span>,
94    /// Optional fix-it / suggestion to surface in CLI output.
95    #[builder(default)]
96    pub suggestion: Option<Arc<str>>,
97    /// Set when this diagnostic is the result of a configured-cap breach
98    /// (`max_blocks_per_file`, `max_attr_depth`, etc.). Lets consumers
99    /// disambiguate without parsing the message.
100    #[builder(default)]
101    pub limit_kind: Option<LimitKind>,
102}
103
104impl Diagnostic {
105    /// Construct a diagnostic.
106    #[must_use]
107    pub fn new(
108        severity: Severity,
109        code: impl Into<Arc<str>>,
110        message: impl Into<Arc<str>>,
111    ) -> Self {
112        Self {
113            severity,
114            code: code.into(),
115            message: message.into(),
116            span: None,
117            suggestion: None,
118            limit_kind: None,
119        }
120    }
121
122    /// Construct a diagnostic for a configured-cap breach.
123    ///
124    /// The structured [`LimitKind`] is preserved as `self.limit_kind` so
125    /// downstream tooling can match on it directly. The `message` should
126    /// still spell out the observed/limit values for log readability.
127    #[must_use]
128    pub fn limit(kind: LimitKind, code: impl Into<Arc<str>>, message: impl Into<Arc<str>>) -> Self {
129        Self {
130            severity: Severity::Warn,
131            code: code.into(),
132            message: message.into(),
133            span: None,
134            suggestion: None,
135            limit_kind: Some(kind),
136        }
137    }
138
139    /// Add a span.
140    #[must_use]
141    pub fn with_span(mut self, span: Span) -> Self {
142        self.span = Some(span);
143        self
144    }
145
146    /// Add a suggestion.
147    #[must_use]
148    pub fn with_suggestion(mut self, suggestion: impl Into<Arc<str>>) -> Self {
149        self.suggestion = Some(suggestion.into());
150        self
151    }
152}
153
154#[cfg(test)]
155#[allow(clippy::unwrap_used)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_should_compose_diagnostic_with_builder_style() {
161        let d = Diagnostic::new(Severity::Warn, "TF1001", "skipped malformed file")
162            .with_suggestion("re-run with --verbose for the parse error");
163        assert_eq!(d.severity, Severity::Warn);
164        assert_eq!(&*d.code, "TF1001");
165        assert!(d.suggestion.is_some());
166    }
167
168    #[test]
169    fn test_should_carry_limit_kind_when_constructed_via_limit() {
170        let d = Diagnostic::limit(LimitKind::BlocksPerFile, "TF1200", "exceeded");
171        assert_eq!(d.severity, Severity::Warn);
172        assert_eq!(d.limit_kind, Some(LimitKind::BlocksPerFile));
173    }
174
175    #[test]
176    fn test_should_serde_round_trip_diagnostic() {
177        let d = Diagnostic::new(Severity::Error, "TG2003", "include cycle detected");
178        let json = serde_json::to_string(&d).unwrap();
179        let back: Diagnostic = serde_json::from_str(&json).unwrap();
180        assert_eq!(d, back);
181    }
182
183    #[test]
184    fn test_should_serialize_severity_kebab_case() {
185        let json = serde_json::to_string(&Severity::Warn).unwrap();
186        assert_eq!(json, "\"warn\"");
187    }
188
189    #[test]
190    fn test_should_serialize_limit_kind_kebab_case() {
191        let json = serde_json::to_string(&LimitKind::IncludeDepth).unwrap();
192        assert_eq!(json, "\"include-depth\"");
193    }
194
195    #[test]
196    fn test_should_order_severities_naturally() {
197        assert!(Severity::Trace < Severity::Info);
198        assert!(Severity::Info < Severity::Warn);
199        assert!(Severity::Warn < Severity::Error);
200    }
201}