Skip to main content

tfparser_core/eval/
adapter.rs

1//! Lossless adapters between our [`Value`](crate::ir::Value) and
2//! [`hcl::Value`].
3//!
4//! Per [13-evaluator.md § 4], the evaluator was meant to "feed our context
5//! into the hcl-rs evaluator and read the result back into our IR." The
6//! actual Phase 4 walker operates entirely on our IR (see
7//! [93-improvements-review.md] S-010 / S-011), but `value_to_hcl` /
8//! `hcl_to_value` still earn their keep:
9//!
10//! - Phase 6's Terragrunt `read_terragrunt_config` returns `hcl::Value`-shaped maps. The adapters
11//!   keep that boundary clean.
12//! - `proptest` harnesses can round-trip arbitrary `Value`s through `hcl::Value` to verify the
13//!   conversion is bijective.
14//!
15//! Both conversions are pure, total, and panic-free. They preserve numeric
16//! fast-path: integer-only `Value::Int` round-trips through `hcl::Number`
17//! without floating-point widening.
18//!
19//! [13-evaluator.md § 4]: ../../../specs/13-evaluator.md
20//! [93-improvements-review.md]: ../../../specs/93-improvements-review.md
21
22use std::sync::Arc;
23
24use hcl::{Map as HclMap, Number, Value as HclValue};
25
26use crate::ir::Value;
27
28/// Convert our [`Value`] into the `hcl::Value` shape `hcl::eval` consumes.
29///
30/// Numeric handling:
31/// - [`Value::Int`] → `hcl::Number::from(i64)` (no float widening).
32/// - [`Value::Number`] → `hcl::Number::from_f64(f)`, with NaN / ±Inf collapsing to `HclValue::Null`
33///   because `hcl::Number` rejects them. The collapse is pinned by a unit test below; callers who
34///   care can pre-filter.
35///
36/// Strings / lists / maps are recursive; insertion order is preserved on
37/// `Value::Map → hcl::Value::Object` because [`hcl::Map`] is
38/// `indexmap::IndexMap`.
39#[must_use]
40pub fn value_to_hcl(v: &Value) -> HclValue {
41    match v {
42        Value::Null => HclValue::Null,
43        Value::Bool(b) => HclValue::Bool(*b),
44        Value::Int(n) => HclValue::Number(Number::from(*n)),
45        Value::Number(f) => Number::from_f64(*f).map_or(HclValue::Null, HclValue::Number),
46        Value::Str(s) => HclValue::String(s.to_string()),
47        Value::List(items) => HclValue::Array(items.iter().map(value_to_hcl).collect()),
48        Value::Map(entries) => {
49            let mut out: HclMap<String, HclValue> = HclMap::with_capacity(entries.len());
50            for (key, val) in entries {
51                out.insert(key.to_string(), value_to_hcl(val));
52            }
53            HclValue::Object(out)
54        }
55    }
56}
57
58/// Convert an `hcl::Value` back into our [`Value`].
59///
60/// Inverse of [`value_to_hcl`]. Numbers prefer the [`Value::Int`] fast path
61/// when `hcl::Number::as_i64` is `Some`; otherwise [`Value::Number`] with
62/// `hcl::Number::as_f64` (which always succeeds for finite numbers because
63/// `hcl::Number` rejects NaN / Inf at construction).
64#[must_use]
65pub fn hcl_to_value(v: &HclValue) -> Value {
66    match v {
67        HclValue::Null => Value::Null,
68        HclValue::Bool(b) => Value::Bool(*b),
69        HclValue::Number(n) => n
70            .as_i64()
71            .map_or_else(|| Value::Number(n.as_f64().unwrap_or(0.0)), Value::Int),
72        HclValue::String(s) => Value::Str(Arc::from(s.as_str())),
73        HclValue::Array(items) => Value::List(items.iter().map(hcl_to_value).collect()),
74        HclValue::Object(entries) => Value::Map(
75            entries
76                .iter()
77                .map(|(k, val)| (Arc::from(k.as_str()), hcl_to_value(val)))
78                .collect(),
79        ),
80    }
81}
82
83#[cfg(test)]
84#[allow(
85    clippy::unwrap_used,
86    clippy::expect_used,
87    clippy::panic,
88    clippy::indexing_slicing
89)]
90mod tests {
91    use super::*;
92    use crate::ir::Map;
93
94    #[test]
95    fn test_should_round_trip_scalars() {
96        for v in [
97            Value::Null,
98            Value::Bool(true),
99            Value::Bool(false),
100            Value::Int(0),
101            Value::Int(42),
102            Value::Int(-1),
103            Value::Int(i64::MAX),
104            Value::Int(i64::MIN),
105            Value::Number(1.5),
106            Value::Str(Arc::from("hello")),
107        ] {
108            let h = value_to_hcl(&v);
109            let back = hcl_to_value(&h);
110            assert_eq!(v, back, "round trip mismatch: {v:?} → {h:?} → {back:?}");
111        }
112    }
113
114    #[test]
115    fn test_should_round_trip_nested_collections() {
116        let v = Value::Map(vec![
117            (Arc::from("a"), Value::Int(1)),
118            (
119                Arc::from("list"),
120                Value::List(vec![Value::Bool(true), Value::Str(Arc::from("x"))]),
121            ),
122            (
123                Arc::from("nested"),
124                Value::Map(vec![(Arc::from("z"), Value::Number(2.5))]),
125            ),
126        ]);
127        let back = hcl_to_value(&value_to_hcl(&v));
128        assert_eq!(v, back);
129    }
130
131    #[test]
132    fn test_should_preserve_map_insertion_order() {
133        let m: Map = vec![
134            (Arc::from("z"), Value::Int(1)),
135            (Arc::from("a"), Value::Int(2)),
136            (Arc::from("m"), Value::Int(3)),
137        ];
138        let v = Value::Map(m.clone());
139        let back = hcl_to_value(&value_to_hcl(&v));
140        let Value::Map(out) = back else {
141            panic!("expected Map");
142        };
143        let keys: Vec<&str> = out.iter().map(|(k, _)| k.as_ref()).collect();
144        assert_eq!(keys, vec!["z", "a", "m"]);
145    }
146
147    #[test]
148    fn test_should_collapse_nan_and_infinity_to_null() {
149        for f in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
150            let v = Value::Number(f);
151            assert_eq!(value_to_hcl(&v), HclValue::Null);
152        }
153    }
154
155    #[test]
156    fn test_should_prefer_int_fast_path_on_back_conversion() {
157        let h = HclValue::Number(Number::from(42_i64));
158        match hcl_to_value(&h) {
159            Value::Int(42) => {}
160            other => panic!("expected Value::Int(42), got {other:?}"),
161        }
162    }
163}