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}