Skip to main content

iac_forge/
sexpr_ir.rs

1//! `ToSExpr` / `FromSExpr` implementations for the IR value types.
2//!
3//! Separate file from [`crate::ir`] to keep the IR itself free of
4//! serialization concerns and from [`crate::sexpr`] to keep the core
5//! value + trait layer free of domain types. All impls here satisfy
6//! the round-trip law `T::from_sexpr(&x.to_sexpr())? == x`, verified by
7//! both concrete unit tests and proptest runs in
8//! [`tests/sexpr_ir_round_trip.rs`].
9
10use crate::ir::{
11    AuthInfo, CrudInfo, IacAction, IacAttribute, IacDataSource, IacProvider, IacResource, IacType,
12    IdentityInfo,
13};
14use crate::sexpr::{FromSExpr, SExpr, SExprError, ToSExpr, parse_struct, struct_expr, take_field};
15
16// ── IacType ─────────────────────────────────────────────────────────
17
18impl ToSExpr for IacType {
19    fn to_sexpr(&self) -> SExpr {
20        match self {
21            Self::String => SExpr::Symbol("string".into()),
22            Self::Integer => SExpr::Symbol("integer".into()),
23            Self::Float => SExpr::Symbol("float".into()),
24            Self::Numeric => SExpr::Symbol("numeric".into()),
25            Self::Boolean => SExpr::Symbol("boolean".into()),
26            Self::Any => SExpr::Symbol("any".into()),
27            Self::List(inner) => SExpr::List(vec![SExpr::Symbol("list".into()), inner.to_sexpr()]),
28            Self::Set(inner) => SExpr::List(vec![SExpr::Symbol("set".into()), inner.to_sexpr()]),
29            Self::Map(inner) => SExpr::List(vec![SExpr::Symbol("map".into()), inner.to_sexpr()]),
30            Self::Object { name, fields } => struct_expr(
31                "object",
32                vec![("name", name.to_sexpr()), ("fields", fields.to_sexpr())],
33            ),
34            Self::Enum { values, underlying } => struct_expr(
35                "enum",
36                vec![
37                    ("values", values.to_sexpr()),
38                    ("underlying", underlying.to_sexpr()),
39                ],
40            ),
41            other => panic!(
42                "unsupported IacType variant in ToSExpr: {other:?} — add an explicit mapping"
43            ),
44        }
45    }
46}
47
48impl FromSExpr for IacType {
49    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
50        // Unit variants: bare symbol.
51        if let SExpr::Symbol(tag) = s {
52            return match tag.as_str() {
53                "string" => Ok(Self::String),
54                "integer" => Ok(Self::Integer),
55                "float" => Ok(Self::Float),
56                "numeric" => Ok(Self::Numeric),
57                "boolean" => Ok(Self::Boolean),
58                "any" => Ok(Self::Any),
59                other => Err(SExprError::UnknownVariant(format!("IacType::{other}"))),
60            };
61        }
62
63        // Parameterized variants: list form.
64        let items = s.as_list()?;
65        let (head, rest) = items
66            .split_first()
67            .ok_or_else(|| SExprError::Shape("empty IacType list".into()))?;
68        let tag = head.as_symbol()?;
69        match tag {
70            "list" | "set" | "map" => {
71                if rest.len() != 1 {
72                    return Err(SExprError::Shape(format!(
73                        "IacType::{tag} expects 1 arg, got {}",
74                        rest.len()
75                    )));
76                }
77                let inner = Box::new(IacType::from_sexpr(&rest[0])?);
78                Ok(match tag {
79                    "list" => Self::List(inner),
80                    "set" => Self::Set(inner),
81                    "map" => Self::Map(inner),
82                    _ => unreachable!(),
83                })
84            }
85            "object" => {
86                let fields = parse_struct(s, "object")?;
87                Ok(Self::Object {
88                    name: String::from_sexpr(take_field(&fields, "name")?)?,
89                    fields: Vec::<IacAttribute>::from_sexpr(take_field(&fields, "fields")?)?,
90                })
91            }
92            "enum" => {
93                let fields = parse_struct(s, "enum")?;
94                Ok(Self::Enum {
95                    values: Vec::<String>::from_sexpr(take_field(&fields, "values")?)?,
96                    underlying: Box::new(IacType::from_sexpr(take_field(&fields, "underlying")?)?),
97                })
98            }
99            other => Err(SExprError::UnknownVariant(format!("IacType::{other}"))),
100        }
101    }
102}
103
104// ── serde_json::Value → SExpr bridge ────────────────────────────────
105//
106// IacAttribute carries an optional JSON default value. We embed it as a
107// canonical JSON string wrapped in a `(json-value <str>)` tag so the
108// surface stays human-readable and round-trips through serde_json.
109
110fn json_to_sexpr(v: &serde_json::Value) -> SExpr {
111    let encoded = serde_json::to_string(v).expect("serde_json::Value is serializable");
112    SExpr::List(vec![
113        SExpr::Symbol("json-value".into()),
114        SExpr::String(encoded),
115    ])
116}
117
118fn json_from_sexpr(s: &SExpr) -> Result<serde_json::Value, SExprError> {
119    let items = s.as_list()?;
120    let (head, rest) = items
121        .split_first()
122        .ok_or_else(|| SExprError::Shape("expected (json-value ...) form".into()))?;
123    let tag = head.as_symbol()?;
124    if tag != "json-value" {
125        return Err(SExprError::Shape(format!(
126            "expected 'json-value' tag, got '{tag}'"
127        )));
128    }
129    if rest.len() != 1 {
130        return Err(SExprError::Shape(format!(
131            "json-value expects 1 arg, got {}",
132            rest.len()
133        )));
134    }
135    let encoded = rest[0].as_str()?;
136    serde_json::from_str(encoded).map_err(|e| SExprError::Parse(format!("json: {e}")))
137}
138
139// ── IacAttribute ────────────────────────────────────────────────────
140
141impl ToSExpr for IacAttribute {
142    fn to_sexpr(&self) -> SExpr {
143        struct_expr(
144            "attribute",
145            vec![
146                ("api-name", self.api_name.to_sexpr()),
147                ("canonical-name", self.canonical_name.to_sexpr()),
148                ("description", self.description.to_sexpr()),
149                ("iac-type", self.iac_type.to_sexpr()),
150                ("required", self.required.to_sexpr()),
151                ("optional", self.optional.to_sexpr()),
152                ("computed", self.computed.to_sexpr()),
153                ("sensitive", self.sensitive.to_sexpr()),
154                ("json-encoded", self.json_encoded.to_sexpr()),
155                ("immutable", self.immutable.to_sexpr()),
156                (
157                    "default-value",
158                    self.default_value
159                        .as_ref()
160                        .map_or(SExpr::Nil, json_to_sexpr),
161                ),
162                ("enum-values", self.enum_values.to_sexpr()),
163                ("read-path", self.read_path.to_sexpr()),
164                ("update-only", self.update_only.to_sexpr()),
165            ],
166        )
167    }
168}
169
170impl FromSExpr for IacAttribute {
171    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
172        let fields = parse_struct(s, "attribute")?;
173        Ok(Self {
174            api_name: String::from_sexpr(take_field(&fields, "api-name")?)?,
175            canonical_name: String::from_sexpr(take_field(&fields, "canonical-name")?)?,
176            description: String::from_sexpr(take_field(&fields, "description")?)?,
177            iac_type: IacType::from_sexpr(take_field(&fields, "iac-type")?)?,
178            required: bool::from_sexpr(take_field(&fields, "required")?)?,
179            optional: bool::from_sexpr(take_field(&fields, "optional")?)?,
180            computed: bool::from_sexpr(take_field(&fields, "computed")?)?,
181            sensitive: bool::from_sexpr(take_field(&fields, "sensitive")?)?,
182            json_encoded: bool::from_sexpr(take_field(&fields, "json-encoded")?)?,
183            immutable: bool::from_sexpr(take_field(&fields, "immutable")?)?,
184            default_value: match take_field(&fields, "default-value")? {
185                SExpr::Nil => None,
186                other => Some(json_from_sexpr(other)?),
187            },
188            enum_values: Option::<Vec<String>>::from_sexpr(take_field(&fields, "enum-values")?)?,
189            read_path: Option::<String>::from_sexpr(take_field(&fields, "read-path")?)?,
190            update_only: bool::from_sexpr(take_field(&fields, "update-only")?)?,
191        })
192    }
193}
194
195// ── CrudInfo ────────────────────────────────────────────────────────
196
197impl ToSExpr for CrudInfo {
198    fn to_sexpr(&self) -> SExpr {
199        struct_expr(
200            "crud",
201            vec![
202                ("create-endpoint", self.create_endpoint.to_sexpr()),
203                ("create-schema", self.create_schema.to_sexpr()),
204                ("update-endpoint", self.update_endpoint.to_sexpr()),
205                ("update-schema", self.update_schema.to_sexpr()),
206                ("read-endpoint", self.read_endpoint.to_sexpr()),
207                ("read-schema", self.read_schema.to_sexpr()),
208                ("read-response-schema", self.read_response_schema.to_sexpr()),
209                ("delete-endpoint", self.delete_endpoint.to_sexpr()),
210                ("delete-schema", self.delete_schema.to_sexpr()),
211            ],
212        )
213    }
214}
215
216impl FromSExpr for CrudInfo {
217    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
218        let f = parse_struct(s, "crud")?;
219        Ok(Self {
220            create_endpoint: String::from_sexpr(take_field(&f, "create-endpoint")?)?,
221            create_schema: String::from_sexpr(take_field(&f, "create-schema")?)?,
222            update_endpoint: Option::<String>::from_sexpr(take_field(&f, "update-endpoint")?)?,
223            update_schema: Option::<String>::from_sexpr(take_field(&f, "update-schema")?)?,
224            read_endpoint: String::from_sexpr(take_field(&f, "read-endpoint")?)?,
225            read_schema: String::from_sexpr(take_field(&f, "read-schema")?)?,
226            read_response_schema: Option::<String>::from_sexpr(take_field(
227                &f,
228                "read-response-schema",
229            )?)?,
230            delete_endpoint: String::from_sexpr(take_field(&f, "delete-endpoint")?)?,
231            delete_schema: String::from_sexpr(take_field(&f, "delete-schema")?)?,
232        })
233    }
234}
235
236// ── IdentityInfo ────────────────────────────────────────────────────
237
238impl ToSExpr for IdentityInfo {
239    fn to_sexpr(&self) -> SExpr {
240        struct_expr(
241            "identity",
242            vec![
243                ("id-field", self.id_field.to_sexpr()),
244                ("import-field", self.import_field.to_sexpr()),
245                ("force-replace-fields", self.force_replace_fields.to_sexpr()),
246            ],
247        )
248    }
249}
250
251impl FromSExpr for IdentityInfo {
252    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
253        let f = parse_struct(s, "identity")?;
254        Ok(Self {
255            id_field: String::from_sexpr(take_field(&f, "id-field")?)?,
256            import_field: String::from_sexpr(take_field(&f, "import-field")?)?,
257            force_replace_fields: Vec::<String>::from_sexpr(take_field(
258                &f,
259                "force-replace-fields",
260            )?)?,
261        })
262    }
263}
264
265// ── AuthInfo ────────────────────────────────────────────────────────
266
267impl ToSExpr for AuthInfo {
268    fn to_sexpr(&self) -> SExpr {
269        struct_expr(
270            "auth",
271            vec![
272                ("token-field", self.token_field.to_sexpr()),
273                ("env-var", self.env_var.to_sexpr()),
274                ("gateway-url-field", self.gateway_url_field.to_sexpr()),
275                ("gateway-env-var", self.gateway_env_var.to_sexpr()),
276            ],
277        )
278    }
279}
280
281impl FromSExpr for AuthInfo {
282    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
283        let f = parse_struct(s, "auth")?;
284        Ok(Self {
285            token_field: String::from_sexpr(take_field(&f, "token-field")?)?,
286            env_var: String::from_sexpr(take_field(&f, "env-var")?)?,
287            gateway_url_field: String::from_sexpr(take_field(&f, "gateway-url-field")?)?,
288            gateway_env_var: String::from_sexpr(take_field(&f, "gateway-env-var")?)?,
289        })
290    }
291}
292
293// ── BTreeMap<String, String> ────────────────────────────────────────
294//
295// Encoded as `(read-mapping (pair "k" "v") ...)` — deterministic since
296// BTreeMap iterates in key order.
297
298fn string_map_to_sexpr(map: &std::collections::BTreeMap<String, String>) -> SExpr {
299    let mut items = Vec::with_capacity(map.len() + 1);
300    items.push(SExpr::Symbol("read-mapping".into()));
301    for (k, v) in map {
302        items.push(SExpr::List(vec![
303            SExpr::Symbol("pair".into()),
304            SExpr::String(k.clone()),
305            SExpr::String(v.clone()),
306        ]));
307    }
308    SExpr::List(items)
309}
310
311fn string_map_from_sexpr(
312    s: &SExpr,
313) -> Result<std::collections::BTreeMap<String, String>, SExprError> {
314    let items = s.as_list()?;
315    let (head, rest) = items
316        .split_first()
317        .ok_or_else(|| SExprError::Shape("expected (read-mapping ...) form".into()))?;
318    let tag = head.as_symbol()?;
319    if tag != "read-mapping" {
320        return Err(SExprError::Shape(format!(
321            "expected 'read-mapping' tag, got '{tag}'"
322        )));
323    }
324    let mut out = std::collections::BTreeMap::new();
325    for pair in rest {
326        let pair_items = pair.as_list()?;
327        let (phead, prest) = pair_items
328            .split_first()
329            .ok_or_else(|| SExprError::Shape("expected (pair k v) form".into()))?;
330        let ptag = phead.as_symbol()?;
331        if ptag != "pair" {
332            return Err(SExprError::Shape(format!(
333                "expected 'pair' tag, got '{ptag}'"
334            )));
335        }
336        if prest.len() != 2 {
337            return Err(SExprError::Shape(format!(
338                "pair expects 2 args, got {}",
339                prest.len()
340            )));
341        }
342        out.insert(
343            String::from_sexpr(&prest[0])?,
344            String::from_sexpr(&prest[1])?,
345        );
346    }
347    Ok(out)
348}
349
350// ── IacResource ─────────────────────────────────────────────────────
351
352impl ToSExpr for IacResource {
353    fn to_sexpr(&self) -> SExpr {
354        struct_expr(
355            "resource",
356            vec![
357                ("name", self.name.to_sexpr()),
358                ("description", self.description.to_sexpr()),
359                ("category", self.category.to_sexpr()),
360                ("crud", self.crud.to_sexpr()),
361                ("attributes", self.attributes.to_sexpr()),
362                ("identity", self.identity.to_sexpr()),
363                ("read-mapping", string_map_to_sexpr(&self.read_mapping)),
364            ],
365        )
366    }
367}
368
369impl FromSExpr for IacResource {
370    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
371        let f = parse_struct(s, "resource")?;
372        Ok(Self {
373            name: String::from_sexpr(take_field(&f, "name")?)?,
374            description: String::from_sexpr(take_field(&f, "description")?)?,
375            category: String::from_sexpr(take_field(&f, "category")?)?,
376            crud: CrudInfo::from_sexpr(take_field(&f, "crud")?)?,
377            attributes: Vec::<IacAttribute>::from_sexpr(take_field(&f, "attributes")?)?,
378            identity: IdentityInfo::from_sexpr(take_field(&f, "identity")?)?,
379            read_mapping: string_map_from_sexpr(take_field(&f, "read-mapping")?)?,
380        })
381    }
382}
383
384// ── IacDataSource ───────────────────────────────────────────────────
385
386impl ToSExpr for IacDataSource {
387    fn to_sexpr(&self) -> SExpr {
388        struct_expr(
389            "data-source",
390            vec![
391                ("name", self.name.to_sexpr()),
392                ("description", self.description.to_sexpr()),
393                ("read-endpoint", self.read_endpoint.to_sexpr()),
394                ("read-schema", self.read_schema.to_sexpr()),
395                ("read-response-schema", self.read_response_schema.to_sexpr()),
396                ("attributes", self.attributes.to_sexpr()),
397                ("read-mapping", string_map_to_sexpr(&self.read_mapping)),
398            ],
399        )
400    }
401}
402
403impl FromSExpr for IacDataSource {
404    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
405        let f = parse_struct(s, "data-source")?;
406        Ok(Self {
407            name: String::from_sexpr(take_field(&f, "name")?)?,
408            description: String::from_sexpr(take_field(&f, "description")?)?,
409            read_endpoint: String::from_sexpr(take_field(&f, "read-endpoint")?)?,
410            read_schema: String::from_sexpr(take_field(&f, "read-schema")?)?,
411            read_response_schema: Option::<String>::from_sexpr(take_field(
412                &f,
413                "read-response-schema",
414            )?)?,
415            attributes: Vec::<IacAttribute>::from_sexpr(take_field(&f, "attributes")?)?,
416            read_mapping: string_map_from_sexpr(take_field(&f, "read-mapping")?)?,
417        })
418    }
419}
420
421// ── IacAction ───────────────────────────────────────────────────────
422
423impl ToSExpr for IacAction {
424    fn to_sexpr(&self) -> SExpr {
425        struct_expr(
426            "action",
427            vec![
428                ("name", self.name.to_sexpr()),
429                ("description", self.description.to_sexpr()),
430                ("category", self.category.to_sexpr()),
431                ("endpoint", self.endpoint.to_sexpr()),
432                ("schema", self.schema.to_sexpr()),
433                ("response-schema", self.response_schema.to_sexpr()),
434                ("mutating", self.mutating.to_sexpr()),
435                (
436                    "sensitive-response-fields",
437                    self.sensitive_response_fields.to_sexpr(),
438                ),
439                ("attributes", self.attributes.to_sexpr()),
440                ("sdk-method", self.sdk_method.to_sexpr()),
441            ],
442        )
443    }
444}
445
446impl FromSExpr for IacAction {
447    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
448        let f = parse_struct(s, "action")?;
449        Ok(Self {
450            name: String::from_sexpr(take_field(&f, "name")?)?,
451            description: String::from_sexpr(take_field(&f, "description")?)?,
452            category: String::from_sexpr(take_field(&f, "category")?)?,
453            endpoint: String::from_sexpr(take_field(&f, "endpoint")?)?,
454            schema: String::from_sexpr(take_field(&f, "schema")?)?,
455            response_schema: Option::<String>::from_sexpr(take_field(&f, "response-schema")?)?,
456            mutating: bool::from_sexpr(take_field(&f, "mutating")?)?,
457            sensitive_response_fields: Vec::<String>::from_sexpr(take_field(
458                &f,
459                "sensitive-response-fields",
460            )?)?,
461            attributes: Vec::<IacAttribute>::from_sexpr(take_field(&f, "attributes")?)?,
462            sdk_method: Option::<String>::from_sexpr(take_field(&f, "sdk-method")?)?,
463        })
464    }
465}
466
467// ── IacProvider ─────────────────────────────────────────────────────
468//
469// `platform_config` is a BTreeMap<String, toml::Value>. We encode it as
470// a canonical TOML string wrapped in `(toml-map <str>)` to preserve the
471// nested structure without losing type info. TOML is already canonical
472// so round-trip is deterministic.
473
474fn toml_map_to_sexpr(map: &std::collections::BTreeMap<String, toml::Value>) -> SExpr {
475    let as_table: toml::Table = map.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
476    let encoded = toml::to_string(&as_table).expect("toml::Table is serializable");
477    SExpr::List(vec![
478        SExpr::Symbol("toml-map".into()),
479        SExpr::String(encoded),
480    ])
481}
482
483fn toml_map_from_sexpr(
484    s: &SExpr,
485) -> Result<std::collections::BTreeMap<String, toml::Value>, SExprError> {
486    let items = s.as_list()?;
487    let (head, rest) = items
488        .split_first()
489        .ok_or_else(|| SExprError::Shape("expected (toml-map ...) form".into()))?;
490    let tag = head.as_symbol()?;
491    if tag != "toml-map" {
492        return Err(SExprError::Shape(format!(
493            "expected 'toml-map' tag, got '{tag}'"
494        )));
495    }
496    if rest.len() != 1 {
497        return Err(SExprError::Shape(format!(
498            "toml-map expects 1 arg, got {}",
499            rest.len()
500        )));
501    }
502    let encoded = rest[0].as_str()?;
503    let table: toml::Table =
504        toml::from_str(encoded).map_err(|e| SExprError::Parse(format!("toml: {e}")))?;
505    Ok(table.into_iter().collect())
506}
507
508impl ToSExpr for IacProvider {
509    fn to_sexpr(&self) -> SExpr {
510        struct_expr(
511            "provider",
512            vec![
513                ("name", self.name.to_sexpr()),
514                ("description", self.description.to_sexpr()),
515                ("version", self.version.to_sexpr()),
516                ("auth", self.auth.to_sexpr()),
517                ("skip-fields", self.skip_fields.to_sexpr()),
518                ("platform-config", toml_map_to_sexpr(&self.platform_config)),
519            ],
520        )
521    }
522}
523
524impl FromSExpr for IacProvider {
525    fn from_sexpr(s: &SExpr) -> Result<Self, SExprError> {
526        let f = parse_struct(s, "provider")?;
527        Ok(Self {
528            name: String::from_sexpr(take_field(&f, "name")?)?,
529            description: String::from_sexpr(take_field(&f, "description")?)?,
530            version: String::from_sexpr(take_field(&f, "version")?)?,
531            auth: AuthInfo::from_sexpr(take_field(&f, "auth")?)?,
532            skip_fields: Vec::<String>::from_sexpr(take_field(&f, "skip-fields")?)?,
533            platform_config: toml_map_from_sexpr(take_field(&f, "platform-config")?)?,
534        })
535    }
536}
537
538// ── Tests ───────────────────────────────────────────────────────────
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543    use crate::testing::{TestAttributeBuilder, test_provider, test_resource};
544
545    // ── IacType ─────────────────────────────────────────────────
546
547    fn roundtrip_type(ty: &IacType) {
548        let s = ty.to_sexpr();
549        let parsed = IacType::from_sexpr(&s).expect("parse");
550        assert_eq!(&parsed, ty, "round-trip failed for {ty:?}");
551        // Also: emit → parse → roundtrip.
552        let emitted = s.emit();
553        let reparsed_sexpr = SExpr::parse(&emitted).expect("parse emitted");
554        let reparsed = IacType::from_sexpr(&reparsed_sexpr).expect("from emitted");
555        assert_eq!(&reparsed, ty, "emit/parse round-trip failed for {ty:?}");
556    }
557
558    #[test]
559    fn round_trip_scalar_types() {
560        for ty in [
561            IacType::String,
562            IacType::Integer,
563            IacType::Float,
564            IacType::Numeric,
565            IacType::Boolean,
566            IacType::Any,
567        ] {
568            roundtrip_type(&ty);
569        }
570    }
571
572    #[test]
573    fn round_trip_list_of_string() {
574        roundtrip_type(&IacType::List(Box::new(IacType::String)));
575    }
576
577    #[test]
578    fn round_trip_set_of_integer() {
579        roundtrip_type(&IacType::Set(Box::new(IacType::Integer)));
580    }
581
582    #[test]
583    fn round_trip_map_of_bool() {
584        roundtrip_type(&IacType::Map(Box::new(IacType::Boolean)));
585    }
586
587    #[test]
588    fn round_trip_nested_list() {
589        roundtrip_type(&IacType::List(Box::new(IacType::List(Box::new(
590            IacType::List(Box::new(IacType::Integer)),
591        )))));
592    }
593
594    #[test]
595    fn round_trip_enum_with_values() {
596        roundtrip_type(&IacType::Enum {
597            values: vec!["tcp".into(), "udp".into(), "icmp".into()],
598            underlying: Box::new(IacType::String),
599        });
600    }
601
602    #[test]
603    fn round_trip_empty_enum() {
604        roundtrip_type(&IacType::Enum {
605            values: vec![],
606            underlying: Box::new(IacType::String),
607        });
608    }
609
610    #[test]
611    fn round_trip_object_with_fields() {
612        let attr = TestAttributeBuilder::new("inner", IacType::String)
613            .required()
614            .build();
615        roundtrip_type(&IacType::Object {
616            name: "Inner".into(),
617            fields: vec![attr],
618        });
619    }
620
621    #[test]
622    fn from_sexpr_rejects_unknown_variant() {
623        let err = IacType::from_sexpr(&SExpr::Symbol("nope".into())).unwrap_err();
624        assert!(matches!(err, SExprError::UnknownVariant(_)));
625    }
626
627    #[test]
628    fn from_sexpr_rejects_wrong_arity_list() {
629        // (list) with no inner
630        let s = SExpr::List(vec![SExpr::Symbol("list".into())]);
631        let err = IacType::from_sexpr(&s).unwrap_err();
632        assert!(matches!(err, SExprError::Shape(_)));
633    }
634
635    // ── IacAttribute ───────────────────────────────────────────
636
637    fn sample_attr() -> IacAttribute {
638        TestAttributeBuilder::new("bound-secret", IacType::String)
639            .required()
640            .immutable()
641            .sensitive()
642            .description("a secret")
643            .build()
644    }
645
646    #[test]
647    fn round_trip_attribute() {
648        let a = sample_attr();
649        let s = a.to_sexpr();
650        let parsed = IacAttribute::from_sexpr(&s).expect("parse");
651        assert_eq!(parsed, a);
652    }
653
654    #[test]
655    fn round_trip_attribute_through_emit() {
656        let a = sample_attr();
657        let emitted = a.to_sexpr().emit();
658        let parsed = IacAttribute::from_sexpr(&SExpr::parse(&emitted).unwrap()).unwrap();
659        assert_eq!(parsed, a);
660    }
661
662    #[test]
663    fn round_trip_attribute_with_default_value() {
664        let attr = TestAttributeBuilder::new("note", IacType::String)
665            .default_value(serde_json::json!("hello"))
666            .build();
667        let parsed = IacAttribute::from_sexpr(&attr.to_sexpr()).expect("parse");
668        assert_eq!(parsed, attr);
669    }
670
671    #[test]
672    fn round_trip_attribute_with_enum_values_and_read_path() {
673        let attr = TestAttributeBuilder::new("protocol", IacType::String)
674            .enum_values(vec!["tcp".into(), "udp".into()])
675            .read_path("resp_path")
676            .build();
677        let parsed = IacAttribute::from_sexpr(&attr.to_sexpr()).expect("parse");
678        assert_eq!(parsed, attr);
679    }
680
681    // ── IacResource ────────────────────────────────────────────
682
683    #[test]
684    fn round_trip_resource() {
685        let r = test_resource("widget");
686        let parsed = IacResource::from_sexpr(&r.to_sexpr()).expect("parse");
687        assert_eq!(parsed.name, r.name);
688        assert_eq!(parsed.description, r.description);
689        assert_eq!(parsed.attributes, r.attributes);
690        assert_eq!(parsed.identity.id_field, r.identity.id_field);
691        assert_eq!(parsed.crud.create_endpoint, r.crud.create_endpoint);
692    }
693
694    #[test]
695    fn round_trip_resource_through_emit() {
696        let r = test_resource("widget");
697        let emitted = r.to_sexpr().emit();
698        let parsed = IacResource::from_sexpr(&SExpr::parse(&emitted).unwrap()).expect("parse");
699        assert_eq!(parsed.name, r.name);
700        assert_eq!(parsed.attributes, r.attributes);
701    }
702
703    // ── IacAction ──────────────────────────────────────────────
704
705    fn sample_action() -> IacAction {
706        IacAction {
707            name: "akeyless_uid_generate_token".into(),
708            description: "Generate a UID token".into(),
709            category: "uid".into(),
710            endpoint: "/uid-generate-token".into(),
711            schema: "uidGenerateToken".into(),
712            response_schema: Some("uidGenerateTokenOutput".into()),
713            mutating: true,
714            sensitive_response_fields: vec!["token".into()],
715            attributes: vec![sample_attr()],
716            sdk_method: None,
717        }
718    }
719
720    #[test]
721    fn round_trip_action() {
722        let a = sample_action();
723        let parsed = IacAction::from_sexpr(&a.to_sexpr()).expect("parse");
724        assert_eq!(parsed.name, a.name);
725        assert_eq!(parsed.endpoint, a.endpoint);
726        assert_eq!(parsed.mutating, a.mutating);
727        assert_eq!(
728            parsed.sensitive_response_fields,
729            a.sensitive_response_fields
730        );
731        assert_eq!(parsed.sdk_method, a.sdk_method);
732    }
733
734    #[test]
735    fn round_trip_action_with_sdk_method_override() {
736        let mut a = sample_action();
737        a.schema = "BatchEncryptionRequestLine".into();
738        a.sdk_method = Some("encrypt_batch".into());
739        let parsed = IacAction::from_sexpr(&a.to_sexpr()).expect("parse");
740        assert_eq!(parsed.sdk_method.as_deref(), Some("encrypt_batch"));
741    }
742
743    #[test]
744    fn round_trip_action_through_emit() {
745        let a = sample_action();
746        let emitted = a.to_sexpr().emit();
747        let parsed = IacAction::from_sexpr(&SExpr::parse(&emitted).unwrap()).expect("parse");
748        assert_eq!(parsed.name, a.name);
749        assert_eq!(parsed.attributes.len(), a.attributes.len());
750    }
751
752    // ── IacProvider ────────────────────────────────────────────
753
754    #[test]
755    fn round_trip_provider() {
756        let p = test_provider("acme");
757        let parsed = IacProvider::from_sexpr(&p.to_sexpr()).expect("parse");
758        assert_eq!(parsed.name, p.name);
759        assert_eq!(parsed.auth.token_field, p.auth.token_field);
760        assert_eq!(parsed.platform_config, p.platform_config);
761    }
762
763    #[test]
764    fn round_trip_provider_through_emit() {
765        let p = test_provider("acme");
766        let emitted = p.to_sexpr().emit();
767        let parsed = IacProvider::from_sexpr(&SExpr::parse(&emitted).unwrap()).expect("parse");
768        assert_eq!(parsed.name, p.name);
769        assert_eq!(parsed.auth.token_field, p.auth.token_field);
770    }
771
772    // ── Canonical form stability ───────────────────────────────
773
774    #[test]
775    fn emitted_form_is_deterministic() {
776        let r = test_resource("widget");
777        let a = r.to_sexpr().emit();
778        let b = r.to_sexpr().emit();
779        assert_eq!(a, b);
780    }
781
782    #[test]
783    fn parse_is_inverse_of_emit_on_all_types() {
784        let types = vec![
785            IacType::String,
786            IacType::Integer,
787            IacType::List(Box::new(IacType::Boolean)),
788            IacType::Enum {
789                values: vec!["a".into(), "b".into()],
790                underlying: Box::new(IacType::String),
791            },
792            IacType::Map(Box::new(IacType::List(Box::new(IacType::String)))),
793        ];
794        for ty in types {
795            let emitted = ty.to_sexpr().emit();
796            let parsed = IacType::from_sexpr(&SExpr::parse(&emitted).unwrap()).unwrap();
797            assert_eq!(parsed, ty);
798        }
799    }
800}