Skip to main content

iac_forge/
nix_backend.rs

1//! Nix backend — emits `IacResource` as a Nix attribute set.
2//!
3//! Ships with iac-forge directly rather than as a sibling crate because
4//! it's small and composes with the existing sexpr + content-hash +
5//! morphism scaffolding with zero extra dependencies.
6//!
7//! Rendered artifacts look like:
8//!
9//! ```nix
10//! # Auto-generated by iac-forge NixBackend. DO NOT EDIT.
11//! { name = "datadog_monitor";
12//!   description = "…";
13//!   category = "monitoring";
14//!   attributes = [
15//!     { name = "name"; type = "string"; required = true; sensitive = false; …; }
16//!     { name = "tags"; type = { kind = "list"; element = "string"; }; required = false; …; }
17//!   ];
18//!   crud = { create_endpoint = "/create-monitor"; …; };
19//!   identity = { id_field = "id"; …; };
20//! }
21//! ```
22//!
23//! The blanket `Backend → ProvenMorphism` impl populates `source_hash`
24//! and `morphism_chain` automatically — no extra plumbing needed.
25
26use crate::backend::{ArtifactKind, Backend, GeneratedArtifact, NamingConvention};
27use crate::error::IacForgeError;
28use crate::ir::{IacAttribute, IacDataSource, IacProvider, IacResource, IacType, IdentityInfo};
29use crate::nix::NixValue;
30use std::collections::BTreeMap;
31
32/// The Nix backend: `Backend` impl producing Nix expression files.
33#[derive(Debug, Default, Clone, Copy)]
34pub struct NixBackend;
35
36struct NixNaming;
37
38impl NamingConvention for NixNaming {
39    fn resource_type_name(&self, resource_name: &str, provider_name: &str) -> String {
40        format!("{provider_name}_{resource_name}")
41    }
42
43    fn file_name(&self, resource_name: &str, kind: &ArtifactKind) -> String {
44        match kind {
45            ArtifactKind::Test => format!("{resource_name}_test.nix"),
46            _ => format!("{resource_name}.nix"),
47        }
48    }
49
50    fn field_name(&self, api_name: &str) -> String {
51        // Nix idioms allow kebab-case identifiers via quoted keys, but
52        // for emission we normalise to snake_case so every field is a
53        // simple identifier.
54        crate::naming::to_snake_case(api_name)
55    }
56}
57
58// ── IR → NixValue conversions ────────────────────────────────────
59
60fn iac_type_to_nix(ty: &IacType) -> NixValue {
61    match ty {
62        IacType::String => NixValue::Str("string".into()),
63        IacType::Integer => NixValue::Str("integer".into()),
64        IacType::Float => NixValue::Str("float".into()),
65        IacType::Numeric => NixValue::Str("numeric".into()),
66        IacType::Boolean => NixValue::Str("boolean".into()),
67        IacType::Any => NixValue::Str("any".into()),
68        IacType::List(inner) => attrs(&[
69            ("kind", NixValue::Str("list".into())),
70            ("element", iac_type_to_nix(inner)),
71        ]),
72        IacType::Set(inner) => attrs(&[
73            ("kind", NixValue::Str("set".into())),
74            ("element", iac_type_to_nix(inner)),
75        ]),
76        IacType::Map(inner) => attrs(&[
77            ("kind", NixValue::Str("map".into())),
78            ("element", iac_type_to_nix(inner)),
79        ]),
80        IacType::Object { name, fields } => attrs(&[
81            ("kind", NixValue::Str("object".into())),
82            ("name", NixValue::Str(name.clone())),
83            (
84                "fields",
85                NixValue::List(fields.iter().map(iac_attribute_to_nix).collect()),
86            ),
87        ]),
88        IacType::Enum { values, underlying } => attrs(&[
89            ("kind", NixValue::Str("enum".into())),
90            (
91                "values",
92                NixValue::List(values.iter().cloned().map(NixValue::Str).collect()),
93            ),
94            ("underlying", iac_type_to_nix(underlying)),
95        ]),
96        other => {
97            panic!("unsupported IacType variant in NixBackend: {other:?} — add an explicit mapping")
98        }
99    }
100}
101
102fn iac_attribute_to_nix(a: &IacAttribute) -> NixValue {
103    let mut pairs: Vec<(&str, NixValue)> = Vec::with_capacity(14);
104    pairs.push(("name", NixValue::Str(a.canonical_name.clone())));
105    pairs.push(("api_name", NixValue::Str(a.api_name.clone())));
106    pairs.push(("description", NixValue::Str(a.description.clone())));
107    pairs.push(("type", iac_type_to_nix(&a.iac_type)));
108    pairs.push(("required", NixValue::Bool(a.required)));
109    pairs.push(("optional", NixValue::Bool(a.optional)));
110    pairs.push(("computed", NixValue::Bool(a.computed)));
111    pairs.push(("sensitive", NixValue::Bool(a.sensitive)));
112    pairs.push(("json_encoded", NixValue::Bool(a.json_encoded)));
113    pairs.push(("immutable", NixValue::Bool(a.immutable)));
114    pairs.push(("update_only", NixValue::Bool(a.update_only)));
115    pairs.push((
116        "read_path",
117        a.read_path
118            .as_ref()
119            .map_or(NixValue::Null, |p| NixValue::Str(p.clone())),
120    ));
121    pairs.push((
122        "enum_values",
123        a.enum_values.as_ref().map_or(NixValue::Null, |vs| {
124            NixValue::List(vs.iter().cloned().map(NixValue::Str).collect())
125        }),
126    ));
127    pairs.push((
128        "default_value",
129        a.default_value
130            .as_ref()
131            .map_or(NixValue::Null, |v| NixValue::Str(v.to_string())),
132    ));
133    attrs(&pairs)
134}
135
136fn identity_to_nix(id: &IdentityInfo) -> NixValue {
137    attrs(&[
138        ("id_field", NixValue::Str(id.id_field.clone())),
139        ("import_field", NixValue::Str(id.import_field.clone())),
140        (
141            "force_replace_fields",
142            NixValue::List(
143                id.force_replace_fields
144                    .iter()
145                    .cloned()
146                    .map(NixValue::Str)
147                    .collect(),
148            ),
149        ),
150    ])
151}
152
153fn crud_to_nix(c: &crate::ir::CrudInfo) -> NixValue {
154    attrs(&[
155        ("create_endpoint", NixValue::Str(c.create_endpoint.clone())),
156        ("create_schema", NixValue::Str(c.create_schema.clone())),
157        (
158            "update_endpoint",
159            c.update_endpoint
160                .as_ref()
161                .map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
162        ),
163        (
164            "update_schema",
165            c.update_schema
166                .as_ref()
167                .map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
168        ),
169        ("read_endpoint", NixValue::Str(c.read_endpoint.clone())),
170        ("read_schema", NixValue::Str(c.read_schema.clone())),
171        (
172            "read_response_schema",
173            c.read_response_schema
174                .as_ref()
175                .map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
176        ),
177        ("delete_endpoint", NixValue::Str(c.delete_endpoint.clone())),
178        ("delete_schema", NixValue::Str(c.delete_schema.clone())),
179    ])
180}
181
182fn resource_to_nix(r: &IacResource) -> NixValue {
183    attrs(&[
184        ("name", NixValue::Str(r.name.clone())),
185        ("description", NixValue::Str(r.description.clone())),
186        ("category", NixValue::Str(r.category.clone())),
187        ("crud", crud_to_nix(&r.crud)),
188        (
189            "attributes",
190            NixValue::List(r.attributes.iter().map(iac_attribute_to_nix).collect()),
191        ),
192        ("identity", identity_to_nix(&r.identity)),
193    ])
194}
195
196fn data_source_to_nix(ds: &IacDataSource) -> NixValue {
197    attrs(&[
198        ("name", NixValue::Str(ds.name.clone())),
199        ("description", NixValue::Str(ds.description.clone())),
200        ("read_endpoint", NixValue::Str(ds.read_endpoint.clone())),
201        ("read_schema", NixValue::Str(ds.read_schema.clone())),
202        (
203            "read_response_schema",
204            ds.read_response_schema
205                .as_ref()
206                .map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
207        ),
208        (
209            "attributes",
210            NixValue::List(ds.attributes.iter().map(iac_attribute_to_nix).collect()),
211        ),
212    ])
213}
214
215fn attrs(pairs: &[(&str, NixValue)]) -> NixValue {
216    let mut map = BTreeMap::new();
217    for (k, v) in pairs {
218        map.insert((*k).to_string(), v.clone());
219    }
220    NixValue::AttrSet(map)
221}
222
223const DO_NOT_EDIT_HEADER: &str = "# Auto-generated by iac-forge NixBackend. DO NOT EDIT.\n";
224
225fn render(value: &NixValue) -> String {
226    let mut out = String::from(DO_NOT_EDIT_HEADER);
227    out.push_str(&value.to_nix_expr());
228    out.push('\n');
229    out
230}
231
232/// Emit an IR resource as a Nix **fixed-output derivation**
233/// content-addressed on the IR's canonical sexpr hash.
234///
235/// This is the convergence-computing realization for IR: the resource
236/// becomes a real Nix build artifact with a deterministic store path.
237/// Re-evaluating with an unchanged IR hits Nix's store cache; any drift
238/// in the IR produces a new hash and therefore a new path.
239///
240/// The emitted file imports nixpkgs and produces a derivation whose:
241/// - `name` is `iac-forge-<provider>-<resource>`
242/// - `outputHashAlgo` is `"blake3"` (requires Nix ≥ 2.19 with
243///   `--extra-experimental-features blake3-hashes`)
244/// - `outputHash` is the resource's content_hash hex
245/// - build script materializes the canonical sexpr text
246///
247/// ```nix
248/// { pkgs ? import <nixpkgs> {} }:
249/// pkgs.runCommand "iac-forge-acme-widget" {
250///   outputHashAlgo = "blake3";
251///   outputHashMode = "flat";
252///   outputHash = "df476bed…";
253///   passAsFile = [ "sexpr" ];
254///   sexpr = "(resource (:name \"widget\") …)";
255/// } ''
256///   cp "$sexprPath" "$out"
257/// ''
258/// ```
259///
260/// Since `outputHash` is `content_hash`, the store path is *exactly*
261/// the IR's identity in Nix terms — one-to-one correspondence between
262/// our ContentHash and Nix's derivation identity.
263pub fn emit_fod(resource: &IacResource, provider: &IacProvider) -> GeneratedArtifact {
264    use crate::sexpr::ToSExpr;
265
266    let sexpr_text = resource.to_sexpr().emit();
267    let hash_hex = resource.content_hash().to_hex();
268    let name = format!("iac-forge-{}-{}", provider.name, resource.name);
269
270    // Escape the sexpr for safe embedding in a Nix string literal.
271    let mut escaped = String::with_capacity(sexpr_text.len() + 16);
272    for c in sexpr_text.chars() {
273        match c {
274            '"' => escaped.push_str("\\\""),
275            '\\' => escaped.push_str("\\\\"),
276            '$' => escaped.push_str("\\$"),
277            c => escaped.push(c),
278        }
279    }
280
281    let content = format!(
282        "{DO_NOT_EDIT_HEADER}\
283         # Fixed-output derivation content-addressed on IR sexpr BLAKE3.\n\
284         # Evaluate with: nix-build --extra-experimental-features blake3-hashes\n\n\
285         {{ pkgs ? import <nixpkgs> {{}} }}:\n\n\
286         pkgs.runCommand \"{name}\" {{\n  \
287           outputHashAlgo = \"blake3\";\n  \
288           outputHashMode = \"flat\";\n  \
289           outputHash = \"{hash_hex}\";\n  \
290           passAsFile = [ \"sexpr\" ];\n  \
291           sexpr = \"{escaped}\";\n\
292         }} ''\n  \
293           cp \"$sexprPath\" \"$out\"\n\
294         ''\n",
295    );
296
297    GeneratedArtifact {
298        path: format!("derivations/{}_{}.nix", provider.name, resource.name),
299        content,
300        kind: ArtifactKind::Metadata,
301        source_hash: hash_hex,
302        morphism_chain: vec!["Backend::nix".to_string(), "emit_fod".to_string()],
303    }
304}
305
306// ── Backend trait impl ───────────────────────────────────────────
307
308impl Backend for NixBackend {
309    fn platform(&self) -> &str {
310        "nix"
311    }
312
313    fn naming(&self) -> &dyn NamingConvention {
314        &NixNaming
315    }
316
317    fn generate_resource(
318        &self,
319        resource: &IacResource,
320        provider: &IacProvider,
321    ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
322        let content = render(&resource_to_nix(resource));
323        let path = format!("resources/{}_{}.nix", provider.name, resource.name,);
324        Ok(vec![GeneratedArtifact::new(
325            path,
326            content,
327            ArtifactKind::Resource,
328        )])
329    }
330
331    fn generate_data_source(
332        &self,
333        ds: &IacDataSource,
334        provider: &IacProvider,
335    ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
336        let content = render(&data_source_to_nix(ds));
337        let path = format!("data_sources/{}_{}.nix", provider.name, ds.name);
338        Ok(vec![GeneratedArtifact::new(
339            path,
340            content,
341            ArtifactKind::DataSource,
342        )])
343    }
344
345    fn generate_provider(
346        &self,
347        provider: &IacProvider,
348        resources: &[IacResource],
349        data_sources: &[IacDataSource],
350    ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
351        let provider_attrs = attrs(&[
352            ("name", NixValue::Str(provider.name.clone())),
353            ("description", NixValue::Str(provider.description.clone())),
354            ("version", NixValue::Str(provider.version.clone())),
355            (
356                "resources",
357                NixValue::List(
358                    resources
359                        .iter()
360                        .map(|r| NixValue::Str(r.name.clone()))
361                        .collect(),
362                ),
363            ),
364            (
365                "data_sources",
366                NixValue::List(
367                    data_sources
368                        .iter()
369                        .map(|d| NixValue::Str(d.name.clone()))
370                        .collect(),
371                ),
372            ),
373        ]);
374        Ok(vec![GeneratedArtifact::new(
375            format!("providers/{}.nix", provider.name),
376            render(&provider_attrs),
377            ArtifactKind::Provider,
378        )])
379    }
380
381    fn generate_test(
382        &self,
383        _resource: &IacResource,
384        _provider: &IacProvider,
385    ) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
386        // Nix testing lives in flake `checks` or nixosTest — out of
387        // scope for the initial backend. Return empty rather than
388        // stub so the backend surface is honest.
389        Ok(vec![])
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::morphism::{Morphism, ProvenMorphism, ResourceInput};
397    use crate::sexpr::ToSExpr;
398    use crate::testing::{TestAttributeBuilder, test_provider, test_resource};
399
400    fn backend() -> NixBackend {
401        NixBackend
402    }
403
404    #[test]
405    fn platform_is_nix() {
406        assert_eq!(backend().platform(), "nix");
407    }
408
409    #[test]
410    fn generate_resource_produces_one_artifact() {
411        let r = test_resource("widget");
412        let p = test_provider("acme");
413        let artifacts = backend().generate_resource(&r, &p).unwrap();
414        assert_eq!(artifacts.len(), 1);
415        assert_eq!(artifacts[0].path, "resources/acme_widget.nix");
416        assert!(artifacts[0].content.starts_with(DO_NOT_EDIT_HEADER));
417    }
418
419    #[test]
420    fn generate_resource_emits_attribute_set() {
421        let r = test_resource("widget");
422        let p = test_provider("acme");
423        let content = &backend().generate_resource(&r, &p).unwrap()[0].content;
424        assert!(content.contains("name = \"widget\""));
425        assert!(content.contains("attributes = ["));
426        assert!(content.contains("crud = "));
427        assert!(content.contains("identity = "));
428    }
429
430    #[test]
431    fn iac_type_list_renders_as_attrset() {
432        let ty = IacType::List(Box::new(IacType::String));
433        let nix = iac_type_to_nix(&ty);
434        let expr = nix.to_nix_expr();
435        assert!(expr.contains("kind = \"list\""));
436        assert!(expr.contains("element = \"string\""));
437    }
438
439    #[test]
440    fn iac_type_enum_emits_values_list() {
441        let ty = IacType::Enum {
442            values: vec!["tcp".into(), "udp".into()],
443            underlying: Box::new(IacType::String),
444        };
445        let expr = iac_type_to_nix(&ty).to_nix_expr();
446        assert!(expr.contains("kind = \"enum\""));
447        assert!(expr.contains("values = [\"tcp\" \"udp\"]"));
448        assert!(expr.contains("underlying = \"string\""));
449    }
450
451    #[test]
452    fn generate_data_source_produces_one_artifact() {
453        use crate::testing::test_data_source;
454        let ds = test_data_source("secret");
455        let p = test_provider("acme");
456        let artifacts = backend().generate_data_source(&ds, &p).unwrap();
457        assert_eq!(artifacts.len(), 1);
458        assert_eq!(artifacts[0].path, "data_sources/acme_secret.nix");
459    }
460
461    #[test]
462    fn generate_provider_lists_resources_and_data_sources() {
463        use crate::testing::test_data_source;
464        let p = test_provider("acme");
465        let rs = vec![test_resource("widget"), test_resource("gadget")];
466        let ds = vec![test_data_source("secret")];
467        let artifacts = backend().generate_provider(&p, &rs, &ds).unwrap();
468        assert_eq!(artifacts.len(), 1);
469        let content = &artifacts[0].content;
470        assert!(content.contains("resources = ["));
471        assert!(content.contains("\"widget\""));
472        assert!(content.contains("\"gadget\""));
473        assert!(content.contains("data_sources = ["));
474        assert!(content.contains("\"secret\""));
475    }
476
477    #[test]
478    fn generate_test_returns_empty_by_design() {
479        // Nix testing lives in flake checks / nixosTest — the backend
480        // intentionally doesn't emit test files.
481        let r = test_resource("widget");
482        let p = test_provider("acme");
483        assert!(backend().generate_test(&r, &p).unwrap().is_empty());
484    }
485
486    // ── Blanket ProvenMorphism provenance ─────────────────────
487
488    #[test]
489    fn nix_backend_inherits_provenance_via_blanket_impl() {
490        let r = test_resource("widget");
491        let p = test_provider("acme");
492        let input = ResourceInput {
493            resource: &r,
494            provider: &p,
495        };
496        let artifacts = <NixBackend as Morphism<_, _>>::apply(&NixBackend, &input);
497        assert_eq!(artifacts.len(), 1);
498        assert_eq!(artifacts[0].source_hash, r.content_hash().to_hex());
499        assert_eq!(artifacts[0].morphism_chain.len(), 2);
500        assert_eq!(artifacts[0].morphism_chain[0], "Backend::nix");
501    }
502
503    #[test]
504    fn nix_backend_passes_blanket_invariants() {
505        let r = test_resource("widget");
506        let p = test_provider("acme");
507        let input = ResourceInput {
508            resource: &r,
509            provider: &p,
510        };
511        let artifacts = <NixBackend as Morphism<_, _>>::apply(&NixBackend, &input);
512        let violations =
513            <NixBackend as ProvenMorphism<_, _>>::check_invariants(&NixBackend, &input, &artifacts);
514        assert!(
515            violations.is_empty(),
516            "blanket invariants must hold: {violations:?}",
517        );
518    }
519
520    // ── Determinism ───────────────────────────────────────────
521
522    #[test]
523    fn generate_resource_is_deterministic() {
524        let r = test_resource("widget");
525        let p = test_provider("acme");
526        let a = backend().generate_resource(&r, &p).unwrap();
527        let b = backend().generate_resource(&r, &p).unwrap();
528        assert_eq!(a, b);
529    }
530
531    #[test]
532    fn nested_attribute_type_is_correct_shape() {
533        let mut r = test_resource("thing");
534        r.attributes = vec![
535            TestAttributeBuilder::new("tags", IacType::List(Box::new(IacType::String))).build(),
536        ];
537        let p = test_provider("acme");
538        let content = &backend().generate_resource(&r, &p).unwrap()[0].content;
539        // The tags attribute's type field should be an attrset
540        // describing kind = "list", element = "string".
541        assert!(content.contains("type = { element = \"string\""));
542        assert!(content.contains("kind = \"list\""));
543    }
544
545    #[test]
546    fn numeric_iac_type_renders_as_numeric() {
547        let mut r = test_resource("thing");
548        r.attributes = vec![
549            TestAttributeBuilder::new("count", IacType::Numeric)
550                .required()
551                .build(),
552        ];
553        let p = test_provider("acme");
554        let content = &backend().generate_resource(&r, &p).unwrap()[0].content;
555        assert!(content.contains("type = \"numeric\""));
556    }
557
558    // ── Fixed-output derivation emission ─────────────────────
559
560    #[test]
561    fn fod_has_resource_hash_as_output_hash() {
562        let r = test_resource("widget");
563        let p = test_provider("acme");
564        let expected_hex = r.content_hash().to_hex();
565        let fod = emit_fod(&r, &p);
566        assert!(
567            fod.content
568                .contains(&format!("outputHash = \"{expected_hex}\""))
569        );
570    }
571
572    #[test]
573    fn fod_names_derivation_iac_forge_prefixed() {
574        let r = test_resource("widget");
575        let p = test_provider("acme");
576        let fod = emit_fod(&r, &p);
577        assert!(fod.content.contains("iac-forge-acme-widget"));
578    }
579
580    #[test]
581    fn fod_path_is_deterministic() {
582        let r = test_resource("widget");
583        let p = test_provider("acme");
584        let a = emit_fod(&r, &p);
585        let b = emit_fod(&r, &p);
586        assert_eq!(a.path, b.path);
587        assert_eq!(a.content, b.content);
588    }
589
590    #[test]
591    fn fod_different_resources_produce_different_hashes() {
592        let p = test_provider("acme");
593        let r1 = test_resource("widget");
594        let r2 = test_resource("gadget");
595        assert_ne!(emit_fod(&r1, &p).source_hash, emit_fod(&r2, &p).source_hash);
596    }
597
598    #[test]
599    fn fod_embeds_sexpr_with_escaping() {
600        // Make sure " and $ inside the sexpr get escaped so the
601        // emitted Nix string literal stays valid.
602        let mut r = test_resource("widget");
603        r.description = "contains \"quotes\" and $vars".into();
604        let p = test_provider("acme");
605        let fod = emit_fod(&r, &p);
606        assert!(fod.content.contains("\\\""), "quotes must be escaped");
607        assert!(fod.content.contains("\\$"), "dollar signs must be escaped");
608    }
609
610    #[test]
611    fn fod_carries_provenance_chain() {
612        let r = test_resource("widget");
613        let p = test_provider("acme");
614        let fod = emit_fod(&r, &p);
615        assert_eq!(fod.morphism_chain, vec!["Backend::nix", "emit_fod"]);
616        assert!(fod.has_provenance());
617    }
618
619    #[test]
620    fn fod_uses_blake3_output_hash_algo() {
621        let r = test_resource("widget");
622        let p = test_provider("acme");
623        let fod = emit_fod(&r, &p);
624        assert!(fod.content.contains("outputHashAlgo = \"blake3\""));
625        assert!(fod.content.contains("outputHashMode = \"flat\""));
626    }
627}