Skip to main content

iac_forge/
nix_transform.rs

1//! Nix-powered IR transforms.
2//!
3//! Shells out to an external Nix evaluator (`nix-instantiate` by
4//! default; aliased to `sui` in sui-enabled environments) to apply a
5//! user-supplied Nix expression over an `IacResource`. The expression
6//! is a single-argument lambda:
7//!
8//! ```nix
9//! # transform.nix
10//! resource: resource // {
11//!   description = "v2 — updated by Nix transform";
12//!   attributes = map (a:
13//!     if a.name == "password" then a // { sensitive = true; } else a
14//!   ) resource.attributes;
15//! }
16//! ```
17//!
18//! Call flow:
19//!
20//! 1. Rust emits the IR as a Nix attribute set via `NixBackend`'s
21//!    `resource_to_nix` converter (internal helper used by this module
22//!    too)
23//! 2. Nix evaluates `(<expression>) <emitted-attrset>` and prints
24//!    JSON via `builtins.toJSON`
25//! 3. Rust reads the JSON output and reconstructs an `IacResource`
26//!
27//! This is the pragmatic realization of "Nix as the extension language
28//! for IR transforms." Sui integration at the Rust library level (linking
29//! `sui-eval` directly, no process boundary) is a natural follow-up; the
30//! API surface here stays the same.
31
32use std::process::{Command, Stdio};
33
34use crate::ir::IacResource;
35use crate::sexpr::{FromSExpr, SExpr, SExprError, ToSExpr};
36
37/// Errors from running a Nix-powered transform.
38#[derive(Debug)]
39pub enum NixTransformError {
40    /// The Nix evaluator wasn't available or failed to launch.
41    EvaluatorUnavailable(String),
42    /// The evaluator ran but returned a non-zero exit; the payload is stderr.
43    EvaluatorFailed(String),
44    /// The JSON output couldn't be decoded into an IacResource.
45    DecodeError(String),
46    /// Sexpr / shape error in the reconstruction.
47    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/// Which evaluator binary to call. `nix-instantiate` is the default;
70/// users can override via `NixEvaluator::Custom("sui".into())` or via
71/// the `NIX_EVALUATOR` environment variable.
72#[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
95/// Apply a Nix lambda expression to an IacResource.
96///
97/// The expression must be a single-argument lambda: `resource: <body>`.
98/// Returns a new IacResource reconstructed from the evaluated output.
99///
100/// # Errors
101/// - `EvaluatorUnavailable` if the binary isn't on PATH
102/// - `EvaluatorFailed` if Nix returned a non-zero exit
103/// - `DecodeError` if the output wasn't a JSON attribute set we can
104///   convert back to an IacResource
105/// - `Sexpr` for shape errors during reconstruction
106pub fn apply_nix_transform(
107    resource: &IacResource,
108    expression: &str,
109    evaluator: &NixEvaluator,
110) -> Result<IacResource, NixTransformError> {
111    // Convert IR → Nix attribute set text.
112    let input_nix = resource_to_nix_text(resource);
113
114    // Build the Nix expression to evaluate. We want JSON output so we
115    // use `builtins.toJSON ((<expression>) <input>)`.
116    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    // Nix prints the JSON string with quotes and escapes; parse it as
133    // a JSON-string-of-JSON.
134    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    // Convert JSON → SExpr → IacResource.
142    let sexpr = json_to_sexpr(&value)?;
143    Ok(IacResource::from_sexpr(&sexpr)?)
144}
145
146// ── Helpers ──────────────────────────────────────────────────────
147
148fn 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
154/// Convert a JSON value produced by a Nix transform back to an SExpr.
155///
156/// The JSON shape mirrors the Nix attribute-set shape we fed in:
157/// scalars → same, arrays → lists, objects → struct-forms keyed on
158/// `head`. The `head` field (if present) becomes the sexpr list's
159/// first element; other fields become `(:key value)` pairs.
160fn 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            // Arrays from Nix go back as plain sexpr lists.
179            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            // Objects go back as struct-form: `head` key becomes the
187            // list head symbol; others become (:key value) pairs.
188            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            // Sort keys for determinism.
198            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/// Check whether the configured evaluator is available on PATH.
213#[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    /// Real end-to-end test — skips gracefully if nix-instantiate
267    /// isn't on PATH, runs the full transform loop when available.
268    #[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        // Identity: `resource: resource`
278        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    /// Apply a real Nix transform that mutates the description.
291    #[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); // everything else preserved
305            }
306            Err(e) => {
307                eprintln!("[skip] transform failed (environment issue): {e}");
308            }
309        }
310    }
311}