use crate::backend::{ArtifactKind, Backend, GeneratedArtifact, NamingConvention};
use crate::error::IacForgeError;
use crate::ir::{IacAttribute, IacDataSource, IacProvider, IacResource, IacType, IdentityInfo};
use crate::nix::NixValue;
use std::collections::BTreeMap;
#[derive(Debug, Default, Clone, Copy)]
pub struct NixBackend;
struct NixNaming;
impl NamingConvention for NixNaming {
fn resource_type_name(&self, resource_name: &str, provider_name: &str) -> String {
format!("{provider_name}_{resource_name}")
}
fn file_name(&self, resource_name: &str, kind: &ArtifactKind) -> String {
match kind {
ArtifactKind::Test => format!("{resource_name}_test.nix"),
_ => format!("{resource_name}.nix"),
}
}
fn field_name(&self, api_name: &str) -> String {
crate::naming::to_snake_case(api_name)
}
}
fn iac_type_to_nix(ty: &IacType) -> NixValue {
match ty {
IacType::String => NixValue::Str("string".into()),
IacType::Integer => NixValue::Str("integer".into()),
IacType::Float => NixValue::Str("float".into()),
IacType::Numeric => NixValue::Str("numeric".into()),
IacType::Boolean => NixValue::Str("boolean".into()),
IacType::Any => NixValue::Str("any".into()),
IacType::List(inner) => attrs(&[
("kind", NixValue::Str("list".into())),
("element", iac_type_to_nix(inner)),
]),
IacType::Set(inner) => attrs(&[
("kind", NixValue::Str("set".into())),
("element", iac_type_to_nix(inner)),
]),
IacType::Map(inner) => attrs(&[
("kind", NixValue::Str("map".into())),
("element", iac_type_to_nix(inner)),
]),
IacType::Object { name, fields } => attrs(&[
("kind", NixValue::Str("object".into())),
("name", NixValue::Str(name.clone())),
(
"fields",
NixValue::List(fields.iter().map(iac_attribute_to_nix).collect()),
),
]),
IacType::Enum { values, underlying } => attrs(&[
("kind", NixValue::Str("enum".into())),
(
"values",
NixValue::List(values.iter().cloned().map(NixValue::Str).collect()),
),
("underlying", iac_type_to_nix(underlying)),
]),
other => {
panic!("unsupported IacType variant in NixBackend: {other:?} — add an explicit mapping")
}
}
}
fn iac_attribute_to_nix(a: &IacAttribute) -> NixValue {
let mut pairs: Vec<(&str, NixValue)> = Vec::with_capacity(14);
pairs.push(("name", NixValue::Str(a.canonical_name.clone())));
pairs.push(("api_name", NixValue::Str(a.api_name.clone())));
pairs.push(("description", NixValue::Str(a.description.clone())));
pairs.push(("type", iac_type_to_nix(&a.iac_type)));
pairs.push(("required", NixValue::Bool(a.required)));
pairs.push(("optional", NixValue::Bool(a.optional)));
pairs.push(("computed", NixValue::Bool(a.computed)));
pairs.push(("sensitive", NixValue::Bool(a.sensitive)));
pairs.push(("json_encoded", NixValue::Bool(a.json_encoded)));
pairs.push(("immutable", NixValue::Bool(a.immutable)));
pairs.push(("update_only", NixValue::Bool(a.update_only)));
pairs.push((
"read_path",
a.read_path
.as_ref()
.map_or(NixValue::Null, |p| NixValue::Str(p.clone())),
));
pairs.push((
"enum_values",
a.enum_values.as_ref().map_or(NixValue::Null, |vs| {
NixValue::List(vs.iter().cloned().map(NixValue::Str).collect())
}),
));
pairs.push((
"default_value",
a.default_value
.as_ref()
.map_or(NixValue::Null, |v| NixValue::Str(v.to_string())),
));
attrs(&pairs)
}
fn identity_to_nix(id: &IdentityInfo) -> NixValue {
attrs(&[
("id_field", NixValue::Str(id.id_field.clone())),
("import_field", NixValue::Str(id.import_field.clone())),
(
"force_replace_fields",
NixValue::List(
id.force_replace_fields
.iter()
.cloned()
.map(NixValue::Str)
.collect(),
),
),
])
}
fn crud_to_nix(c: &crate::ir::CrudInfo) -> NixValue {
attrs(&[
("create_endpoint", NixValue::Str(c.create_endpoint.clone())),
("create_schema", NixValue::Str(c.create_schema.clone())),
(
"update_endpoint",
c.update_endpoint
.as_ref()
.map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
),
(
"update_schema",
c.update_schema
.as_ref()
.map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
),
("read_endpoint", NixValue::Str(c.read_endpoint.clone())),
("read_schema", NixValue::Str(c.read_schema.clone())),
(
"read_response_schema",
c.read_response_schema
.as_ref()
.map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
),
("delete_endpoint", NixValue::Str(c.delete_endpoint.clone())),
("delete_schema", NixValue::Str(c.delete_schema.clone())),
])
}
fn resource_to_nix(r: &IacResource) -> NixValue {
attrs(&[
("name", NixValue::Str(r.name.clone())),
("description", NixValue::Str(r.description.clone())),
("category", NixValue::Str(r.category.clone())),
("crud", crud_to_nix(&r.crud)),
(
"attributes",
NixValue::List(r.attributes.iter().map(iac_attribute_to_nix).collect()),
),
("identity", identity_to_nix(&r.identity)),
])
}
fn data_source_to_nix(ds: &IacDataSource) -> NixValue {
attrs(&[
("name", NixValue::Str(ds.name.clone())),
("description", NixValue::Str(ds.description.clone())),
("read_endpoint", NixValue::Str(ds.read_endpoint.clone())),
("read_schema", NixValue::Str(ds.read_schema.clone())),
(
"read_response_schema",
ds.read_response_schema
.as_ref()
.map_or(NixValue::Null, |s| NixValue::Str(s.clone())),
),
(
"attributes",
NixValue::List(ds.attributes.iter().map(iac_attribute_to_nix).collect()),
),
])
}
fn attrs(pairs: &[(&str, NixValue)]) -> NixValue {
let mut map = BTreeMap::new();
for (k, v) in pairs {
map.insert((*k).to_string(), v.clone());
}
NixValue::AttrSet(map)
}
const DO_NOT_EDIT_HEADER: &str = "# Auto-generated by iac-forge NixBackend. DO NOT EDIT.\n";
fn render(value: &NixValue) -> String {
let mut out = String::from(DO_NOT_EDIT_HEADER);
out.push_str(&value.to_nix_expr());
out.push('\n');
out
}
pub fn emit_fod(resource: &IacResource, provider: &IacProvider) -> GeneratedArtifact {
use crate::sexpr::ToSExpr;
let sexpr_text = resource.to_sexpr().emit();
let hash_hex = resource.content_hash().to_hex();
let name = format!("iac-forge-{}-{}", provider.name, resource.name);
let mut escaped = String::with_capacity(sexpr_text.len() + 16);
for c in sexpr_text.chars() {
match c {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'$' => escaped.push_str("\\$"),
c => escaped.push(c),
}
}
let content = format!(
"{DO_NOT_EDIT_HEADER}\
# Fixed-output derivation content-addressed on IR sexpr BLAKE3.\n\
# Evaluate with: nix-build --extra-experimental-features blake3-hashes\n\n\
{{ pkgs ? import <nixpkgs> {{}} }}:\n\n\
pkgs.runCommand \"{name}\" {{\n \
outputHashAlgo = \"blake3\";\n \
outputHashMode = \"flat\";\n \
outputHash = \"{hash_hex}\";\n \
passAsFile = [ \"sexpr\" ];\n \
sexpr = \"{escaped}\";\n\
}} ''\n \
cp \"$sexprPath\" \"$out\"\n\
''\n",
);
GeneratedArtifact {
path: format!("derivations/{}_{}.nix", provider.name, resource.name),
content,
kind: ArtifactKind::Metadata,
source_hash: hash_hex,
morphism_chain: vec!["Backend::nix".to_string(), "emit_fod".to_string()],
}
}
impl Backend for NixBackend {
fn platform(&self) -> &str {
"nix"
}
fn naming(&self) -> &dyn NamingConvention {
&NixNaming
}
fn generate_resource(
&self,
resource: &IacResource,
provider: &IacProvider,
) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
let content = render(&resource_to_nix(resource));
let path = format!("resources/{}_{}.nix", provider.name, resource.name,);
Ok(vec![GeneratedArtifact::new(
path,
content,
ArtifactKind::Resource,
)])
}
fn generate_data_source(
&self,
ds: &IacDataSource,
provider: &IacProvider,
) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
let content = render(&data_source_to_nix(ds));
let path = format!("data_sources/{}_{}.nix", provider.name, ds.name);
Ok(vec![GeneratedArtifact::new(
path,
content,
ArtifactKind::DataSource,
)])
}
fn generate_provider(
&self,
provider: &IacProvider,
resources: &[IacResource],
data_sources: &[IacDataSource],
) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
let provider_attrs = attrs(&[
("name", NixValue::Str(provider.name.clone())),
("description", NixValue::Str(provider.description.clone())),
("version", NixValue::Str(provider.version.clone())),
(
"resources",
NixValue::List(
resources
.iter()
.map(|r| NixValue::Str(r.name.clone()))
.collect(),
),
),
(
"data_sources",
NixValue::List(
data_sources
.iter()
.map(|d| NixValue::Str(d.name.clone()))
.collect(),
),
),
]);
Ok(vec![GeneratedArtifact::new(
format!("providers/{}.nix", provider.name),
render(&provider_attrs),
ArtifactKind::Provider,
)])
}
fn generate_test(
&self,
_resource: &IacResource,
_provider: &IacProvider,
) -> Result<Vec<GeneratedArtifact>, IacForgeError> {
Ok(vec![])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::morphism::{Morphism, ProvenMorphism, ResourceInput};
use crate::sexpr::ToSExpr;
use crate::testing::{TestAttributeBuilder, test_provider, test_resource};
fn backend() -> NixBackend {
NixBackend
}
#[test]
fn platform_is_nix() {
assert_eq!(backend().platform(), "nix");
}
#[test]
fn generate_resource_produces_one_artifact() {
let r = test_resource("widget");
let p = test_provider("acme");
let artifacts = backend().generate_resource(&r, &p).unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].path, "resources/acme_widget.nix");
assert!(artifacts[0].content.starts_with(DO_NOT_EDIT_HEADER));
}
#[test]
fn generate_resource_emits_attribute_set() {
let r = test_resource("widget");
let p = test_provider("acme");
let content = &backend().generate_resource(&r, &p).unwrap()[0].content;
assert!(content.contains("name = \"widget\""));
assert!(content.contains("attributes = ["));
assert!(content.contains("crud = "));
assert!(content.contains("identity = "));
}
#[test]
fn iac_type_list_renders_as_attrset() {
let ty = IacType::List(Box::new(IacType::String));
let nix = iac_type_to_nix(&ty);
let expr = nix.to_nix_expr();
assert!(expr.contains("kind = \"list\""));
assert!(expr.contains("element = \"string\""));
}
#[test]
fn iac_type_enum_emits_values_list() {
let ty = IacType::Enum {
values: vec!["tcp".into(), "udp".into()],
underlying: Box::new(IacType::String),
};
let expr = iac_type_to_nix(&ty).to_nix_expr();
assert!(expr.contains("kind = \"enum\""));
assert!(expr.contains("values = [\"tcp\" \"udp\"]"));
assert!(expr.contains("underlying = \"string\""));
}
#[test]
fn generate_data_source_produces_one_artifact() {
use crate::testing::test_data_source;
let ds = test_data_source("secret");
let p = test_provider("acme");
let artifacts = backend().generate_data_source(&ds, &p).unwrap();
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].path, "data_sources/acme_secret.nix");
}
#[test]
fn generate_provider_lists_resources_and_data_sources() {
use crate::testing::test_data_source;
let p = test_provider("acme");
let rs = vec![test_resource("widget"), test_resource("gadget")];
let ds = vec![test_data_source("secret")];
let artifacts = backend().generate_provider(&p, &rs, &ds).unwrap();
assert_eq!(artifacts.len(), 1);
let content = &artifacts[0].content;
assert!(content.contains("resources = ["));
assert!(content.contains("\"widget\""));
assert!(content.contains("\"gadget\""));
assert!(content.contains("data_sources = ["));
assert!(content.contains("\"secret\""));
}
#[test]
fn generate_test_returns_empty_by_design() {
let r = test_resource("widget");
let p = test_provider("acme");
assert!(backend().generate_test(&r, &p).unwrap().is_empty());
}
#[test]
fn nix_backend_inherits_provenance_via_blanket_impl() {
let r = test_resource("widget");
let p = test_provider("acme");
let input = ResourceInput {
resource: &r,
provider: &p,
};
let artifacts = <NixBackend as Morphism<_, _>>::apply(&NixBackend, &input);
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].source_hash, r.content_hash().to_hex());
assert_eq!(artifacts[0].morphism_chain.len(), 2);
assert_eq!(artifacts[0].morphism_chain[0], "Backend::nix");
}
#[test]
fn nix_backend_passes_blanket_invariants() {
let r = test_resource("widget");
let p = test_provider("acme");
let input = ResourceInput {
resource: &r,
provider: &p,
};
let artifacts = <NixBackend as Morphism<_, _>>::apply(&NixBackend, &input);
let violations =
<NixBackend as ProvenMorphism<_, _>>::check_invariants(&NixBackend, &input, &artifacts);
assert!(
violations.is_empty(),
"blanket invariants must hold: {violations:?}",
);
}
#[test]
fn generate_resource_is_deterministic() {
let r = test_resource("widget");
let p = test_provider("acme");
let a = backend().generate_resource(&r, &p).unwrap();
let b = backend().generate_resource(&r, &p).unwrap();
assert_eq!(a, b);
}
#[test]
fn nested_attribute_type_is_correct_shape() {
let mut r = test_resource("thing");
r.attributes = vec![
TestAttributeBuilder::new("tags", IacType::List(Box::new(IacType::String))).build(),
];
let p = test_provider("acme");
let content = &backend().generate_resource(&r, &p).unwrap()[0].content;
assert!(content.contains("type = { element = \"string\""));
assert!(content.contains("kind = \"list\""));
}
#[test]
fn numeric_iac_type_renders_as_numeric() {
let mut r = test_resource("thing");
r.attributes = vec![
TestAttributeBuilder::new("count", IacType::Numeric)
.required()
.build(),
];
let p = test_provider("acme");
let content = &backend().generate_resource(&r, &p).unwrap()[0].content;
assert!(content.contains("type = \"numeric\""));
}
#[test]
fn fod_has_resource_hash_as_output_hash() {
let r = test_resource("widget");
let p = test_provider("acme");
let expected_hex = r.content_hash().to_hex();
let fod = emit_fod(&r, &p);
assert!(
fod.content
.contains(&format!("outputHash = \"{expected_hex}\""))
);
}
#[test]
fn fod_names_derivation_iac_forge_prefixed() {
let r = test_resource("widget");
let p = test_provider("acme");
let fod = emit_fod(&r, &p);
assert!(fod.content.contains("iac-forge-acme-widget"));
}
#[test]
fn fod_path_is_deterministic() {
let r = test_resource("widget");
let p = test_provider("acme");
let a = emit_fod(&r, &p);
let b = emit_fod(&r, &p);
assert_eq!(a.path, b.path);
assert_eq!(a.content, b.content);
}
#[test]
fn fod_different_resources_produce_different_hashes() {
let p = test_provider("acme");
let r1 = test_resource("widget");
let r2 = test_resource("gadget");
assert_ne!(emit_fod(&r1, &p).source_hash, emit_fod(&r2, &p).source_hash);
}
#[test]
fn fod_embeds_sexpr_with_escaping() {
let mut r = test_resource("widget");
r.description = "contains \"quotes\" and $vars".into();
let p = test_provider("acme");
let fod = emit_fod(&r, &p);
assert!(fod.content.contains("\\\""), "quotes must be escaped");
assert!(fod.content.contains("\\$"), "dollar signs must be escaped");
}
#[test]
fn fod_carries_provenance_chain() {
let r = test_resource("widget");
let p = test_provider("acme");
let fod = emit_fod(&r, &p);
assert_eq!(fod.morphism_chain, vec!["Backend::nix", "emit_fod"]);
assert!(fod.has_provenance());
}
#[test]
fn fod_uses_blake3_output_hash_algo() {
let r = test_resource("widget");
let p = test_provider("acme");
let fod = emit_fod(&r, &p);
assert!(fod.content.contains("outputHashAlgo = \"blake3\""));
assert!(fod.content.contains("outputHashMode = \"flat\""));
}
}