iac_forge/
nix_transform.rs1use std::process::{Command, Stdio};
33
34use crate::ir::IacResource;
35use crate::sexpr::{FromSExpr, SExpr, SExprError, ToSExpr};
36
37#[derive(Debug)]
39pub enum NixTransformError {
40 EvaluatorUnavailable(String),
42 EvaluatorFailed(String),
44 DecodeError(String),
46 Sexpr(SExprError),
48}
49
50impl std::fmt::Display for NixTransformError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 Self::EvaluatorUnavailable(s) => write!(f, "nix evaluator unavailable: {s}"),
54 Self::EvaluatorFailed(s) => write!(f, "nix evaluator failed: {s}"),
55 Self::DecodeError(s) => write!(f, "nix transform decode: {s}"),
56 Self::Sexpr(e) => write!(f, "nix transform sexpr: {e}"),
57 }
58 }
59}
60
61impl std::error::Error for NixTransformError {}
62
63impl From<SExprError> for NixTransformError {
64 fn from(e: SExprError) -> Self {
65 Self::Sexpr(e)
66 }
67}
68
69#[derive(Debug, Clone)]
73pub enum NixEvaluator {
74 Default,
75 Custom(String),
76}
77
78impl NixEvaluator {
79 fn binary(&self) -> String {
80 match self {
81 Self::Default => {
82 std::env::var("NIX_EVALUATOR").unwrap_or_else(|_| "nix-instantiate".to_string())
83 }
84 Self::Custom(s) => s.clone(),
85 }
86 }
87}
88
89impl Default for NixEvaluator {
90 fn default() -> Self {
91 Self::Default
92 }
93}
94
95pub fn apply_nix_transform(
107 resource: &IacResource,
108 expression: &str,
109 evaluator: &NixEvaluator,
110) -> Result<IacResource, NixTransformError> {
111 let input_nix = resource_to_nix_text(resource);
113
114 let full_expr = format!("builtins.toJSON (({expression}) {input_nix})");
117
118 let binary = evaluator.binary();
119 let out = Command::new(&binary)
120 .args(["--eval", "--expr", &full_expr])
121 .stdin(Stdio::null())
122 .stdout(Stdio::piped())
123 .stderr(Stdio::piped())
124 .output()
125 .map_err(|e| NixTransformError::EvaluatorUnavailable(e.to_string()))?;
126
127 if !out.status.success() {
128 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
129 return Err(NixTransformError::EvaluatorFailed(stderr));
130 }
131
132 let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
135 let inner_json: String = serde_json::from_str(&stdout)
136 .map_err(|e| NixTransformError::DecodeError(format!("outer json: {e}")))?;
137
138 let value: serde_json::Value = serde_json::from_str(&inner_json)
139 .map_err(|e| NixTransformError::DecodeError(format!("inner json: {e}")))?;
140
141 let sexpr = json_to_sexpr(&value)?;
143 Ok(IacResource::from_sexpr(&sexpr)?)
144}
145
146fn resource_to_nix_text(resource: &IacResource) -> String {
149 use crate::nix::NixValue;
150 let nv = NixValue::from_sexpr(&resource.to_sexpr());
151 nv.to_nix_expr()
152}
153
154fn json_to_sexpr(v: &serde_json::Value) -> Result<SExpr, NixTransformError> {
161 use serde_json::Value as J;
162 match v {
163 J::Null => Ok(SExpr::Nil),
164 J::Bool(b) => Ok(SExpr::Bool(*b)),
165 J::Number(n) => {
166 if let Some(i) = n.as_i64() {
167 Ok(SExpr::Integer(i))
168 } else if let Some(f) = n.as_f64() {
169 Ok(SExpr::Float(f))
170 } else {
171 Err(NixTransformError::DecodeError(format!(
172 "non-representable number: {n}"
173 )))
174 }
175 }
176 J::String(s) => Ok(SExpr::String(s.clone())),
177 J::Array(items) => {
178 let mut out = Vec::with_capacity(items.len());
180 for item in items {
181 out.push(json_to_sexpr(item)?);
182 }
183 Ok(SExpr::List(out))
184 }
185 J::Object(map) => {
186 let head_name = match map.get("head") {
189 Some(J::String(s)) => s.clone(),
190 _ => {
191 return Err(NixTransformError::DecodeError(
192 "object missing `head` key — can't reconstruct struct-form".into(),
193 ));
194 }
195 };
196 let mut out = vec![SExpr::Symbol(head_name)];
197 let mut keys: Vec<&String> = map.keys().collect();
199 keys.sort();
200 for k in keys {
201 if k == "head" {
202 continue;
203 }
204 let v = json_to_sexpr(&map[k])?;
205 out.push(SExpr::List(vec![SExpr::Symbol(format!(":{k}")), v]));
206 }
207 Ok(SExpr::List(out))
208 }
209 }
210}
211
212#[must_use]
214pub fn evaluator_available(evaluator: &NixEvaluator) -> bool {
215 Command::new(evaluator.binary())
216 .arg("--version")
217 .output()
218 .is_ok()
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::testing::test_resource;
225
226 #[test]
227 fn json_null_becomes_nil() {
228 let v: serde_json::Value = serde_json::from_str("null").unwrap();
229 assert_eq!(json_to_sexpr(&v).unwrap(), SExpr::Nil);
230 }
231
232 #[test]
233 fn json_bool_and_int_preserved() {
234 assert_eq!(
235 json_to_sexpr(&serde_json::from_str("true").unwrap()).unwrap(),
236 SExpr::Bool(true)
237 );
238 assert_eq!(
239 json_to_sexpr(&serde_json::from_str("42").unwrap()).unwrap(),
240 SExpr::Integer(42)
241 );
242 }
243
244 #[test]
245 fn json_object_without_head_rejected() {
246 let v: serde_json::Value = serde_json::from_str(r#"{"a":1}"#).unwrap();
247 let err = json_to_sexpr(&v).unwrap_err();
248 assert!(matches!(err, NixTransformError::DecodeError(_)));
249 }
250
251 #[test]
252 fn resource_to_nix_text_is_valid_shape() {
253 let r = test_resource("widget");
254 let nix = resource_to_nix_text(&r);
255 assert!(nix.starts_with("{ "));
256 assert!(nix.contains("head = \"resource\""));
257 assert!(nix.contains("name"));
258 }
259
260 #[test]
261 fn evaluator_available_returns_false_for_bogus_binary() {
262 let ev = NixEvaluator::Custom("definitely-not-a-real-binary-xyzzy".into());
263 assert!(!evaluator_available(&ev));
264 }
265
266 #[test]
269 fn identity_transform_preserves_the_resource() {
270 let ev = NixEvaluator::Default;
271 if !evaluator_available(&ev) {
272 eprintln!("[skip] nix evaluator not available");
273 return;
274 }
275
276 let r = test_resource("widget");
277 let result = apply_nix_transform(&r, "resource: resource", &ev);
279 match result {
280 Ok(round) => {
281 assert_eq!(round.name, r.name);
282 assert_eq!(round.attributes.len(), r.attributes.len());
283 }
284 Err(e) => {
285 eprintln!("[skip] transform failed (environment issue): {e}");
286 }
287 }
288 }
289
290 #[test]
292 fn nix_transform_can_mutate_description() {
293 let ev = NixEvaluator::Default;
294 if !evaluator_available(&ev) {
295 eprintln!("[skip] nix evaluator not available");
296 return;
297 }
298
299 let r = test_resource("widget");
300 let expr = r#"resource: resource // { description = "transformed"; }"#;
301 match apply_nix_transform(&r, expr, &ev) {
302 Ok(round) => {
303 assert_eq!(round.description, "transformed");
304 assert_eq!(round.name, r.name); }
306 Err(e) => {
307 eprintln!("[skip] transform failed (environment issue): {e}");
308 }
309 }
310 }
311}