1use 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#[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 crate::naming::to_snake_case(api_name)
55 }
56}
57
58fn 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
232pub 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 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
306impl 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 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 let r = test_resource("widget");
482 let p = test_provider("acme");
483 assert!(backend().generate_test(&r, &p).unwrap().is_empty());
484 }
485
486 #[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 #[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 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 #[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 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}