Skip to main content

tfparser_core/eval/
error.rs

1//! Evaluator-specific errors.
2//!
3//! Per [13-evaluator.md § 8]: cycle detection, configurable limit breaches,
4//! function-call failures, and path-escape attempts get a structured
5//! variant. An *unbound* reference (e.g. `var.x` when nothing supplied it)
6//! is **not** an error — the walker leaves it as
7//! [`crate::ir::Expression::Unresolved`] and continues. That is the
8//! best-effort contract pinned in [99-key-decisions.md] D4.
9//!
10//! [13-evaluator.md § 8]: ../../../specs/13-evaluator.md
11//! [99-key-decisions.md]: ../../../specs/99-key-decisions.md
12
13use std::{path::PathBuf, sync::Arc};
14
15use thiserror::Error;
16
17use crate::{diagnostic::LimitKind, ir::Address};
18
19/// Recoverable evaluator failure.
20///
21/// `EvalError` is **not** propagated out of
22/// [`crate::eval::Evaluator::evaluate`]: instead the evaluator records each
23/// failure as a [`crate::Diagnostic`] on the returned
24/// [`crate::eval::EvaluatedComponent`] and proceeds. The error type is
25/// public so callers (and tests) can build the same diagnostic shape
26/// elsewhere — see [`HclEvaluator`](crate::eval::HclEvaluator) for the
27/// conversion path.
28#[derive(Debug, Clone, Error, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum EvalError {
31    /// A cycle was detected in `locals`. The participants list is the cycle
32    /// in order of first observation (deterministic — sorted by name) so
33    /// the diagnostic is stable across runs.
34    #[error("cycle in locals: {participants:?}")]
35    Cycle {
36        /// The locals participating in the cycle (by `Address`, e.g.
37        /// `"local.a"`).
38        participants: Vec<Address>,
39    },
40
41    /// A configured limit fired. `kind` identifies which one; the message
42    /// embeds the observed and configured values.
43    #[error("evaluator limit ({kind:?}): observed {observed} > {limit}")]
44    Limit {
45        /// Which limit category fired.
46        kind: LimitKind,
47        /// Observed value at the time of the breach.
48        observed: u64,
49        /// Configured limit.
50        limit: u64,
51    },
52
53    /// A registered function returned an error. `name` is the function
54    /// being called; `message` is the function's own diagnostic. Function
55    /// failures are recoverable: the call site keeps the unresolved
56    /// expression and the workspace surfaces a diagnostic.
57    #[error("function `{name}` failed: {message}")]
58    Func {
59        /// The function name.
60        name: Arc<str>,
61        /// Function-supplied message.
62        message: Arc<str>,
63    },
64
65    /// A sandboxed file function rejected a path because it resolved
66    /// outside the workspace root.
67    #[error("path escape in `{func}`: `{path}`")]
68    PathEscape {
69        /// Function name (`"file"`, `"templatefile"`, …).
70        func: &'static str,
71        /// Path supplied by the caller.
72        path: PathBuf,
73    },
74}
75
76#[cfg(test)]
77#[allow(
78    clippy::unwrap_used,
79    clippy::expect_used,
80    clippy::panic,
81    clippy::indexing_slicing
82)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_should_render_cycle_with_participants() {
88        let e = EvalError::Cycle {
89            participants: vec![
90                Address::new("local.a").expect("addr"),
91                Address::new("local.b").expect("addr"),
92            ],
93        };
94        let s = format!("{e}");
95        assert!(s.contains("local.a"));
96        assert!(s.contains("local.b"));
97    }
98
99    #[test]
100    fn test_should_render_limit_with_kind_and_values() {
101        let e = EvalError::Limit {
102            kind: LimitKind::EvalIterations,
103            observed: 10,
104            limit: 5,
105        };
106        let s = format!("{e}");
107        assert!(s.contains("10"));
108        assert!(s.contains('5'));
109    }
110
111    #[test]
112    fn test_should_render_path_escape_with_func_and_path() {
113        let e = EvalError::PathEscape {
114            func: "file",
115            path: PathBuf::from("../../etc/passwd"),
116        };
117        let s = format!("{e}");
118        assert!(s.contains("file"));
119        assert!(s.contains("etc/passwd"));
120    }
121}