Skip to main content

iac_forge/
transform.rs

1//! User-extensible IR transforms.
2//!
3//! A `Transform<T>` is an endomorphism — it takes a `T` and returns a
4//! modified `T`. This is how a user extends the pipeline without
5//! recompiling the core: they declare transforms as data (or script
6//! them in a minimal s-expr surface), stack them, and slot them into
7//! the generation pipeline before the backend runs.
8//!
9//! Transforms compose: `ComposeTransforms(a, b).apply(x)` == `b.apply(a.apply(x))`.
10//! `Identity` is the unit — useful as a default and as a sentinel.
11//!
12//! The `ops` submodule provides a minimal, data-level operation enum
13//! (`ResourceOp`) covering the most common IR edits: add a tag, rename
14//! an attribute, set a description, flip a sensitivity flag. Users who
15//! need richer transforms can either add variants or implement their
16//! own `Transform<IacResource>` in Rust.
17//!
18//! The `script` submodule (feature-gated, `script` feature) provides a
19//! tiny s-expression reader that compiles a script string into a
20//! `Vec<ResourceOp>`. That is the "embedded Lisp" surface of the
21//! pipeline — code-as-data parse, interpreted to structured ops.
22//! Sufficient for the usual extension cases; full scripting power can
23//! later slot in behind the same trait.
24
25use crate::ir::IacResource;
26
27/// A structure-preserving mutation of a value.
28pub trait Transform<T> {
29    /// Human-readable name for diagnostics.
30    fn name(&self) -> &'static str;
31
32    /// Apply the transform, returning a new value. Must be deterministic.
33    fn apply(&self, value: T) -> T;
34}
35
36/// Identity transform — returns its input unchanged.
37#[derive(Debug, Default, Clone, Copy)]
38pub struct Identity;
39
40impl<T> Transform<T> for Identity {
41    fn name(&self) -> &'static str {
42        "Identity"
43    }
44    fn apply(&self, value: T) -> T {
45        value
46    }
47}
48
49/// Sequential composition: apply `a`, then `b`.
50pub struct ComposeTransforms<A, B>(pub A, pub B);
51
52impl<T, A, B> Transform<T> for ComposeTransforms<A, B>
53where
54    A: Transform<T>,
55    B: Transform<T>,
56{
57    fn name(&self) -> &'static str {
58        "ComposeTransforms"
59    }
60    fn apply(&self, value: T) -> T {
61        self.1.apply(self.0.apply(value))
62    }
63}
64
65/// Data-level IR operations.
66///
67/// These are the "atoms" of the transform language. Any more complex
68/// transform either composes these or drops down to Rust.
69pub mod ops {
70    use super::{IacResource, Transform};
71    use crate::ir::{IacAttribute, IacType};
72
73    /// A single declarative edit to an `IacResource`.
74    #[derive(Debug, Clone, PartialEq, Eq)]
75    #[non_exhaustive]
76    pub enum ResourceOp {
77        /// Set the resource's description.
78        SetDescription(String),
79        /// Set the resource's category.
80        SetCategory(String),
81        /// Mark an attribute (by canonical name) as sensitive.
82        MarkSensitive(String),
83        /// Append a new optional String attribute if one with that
84        /// `canonical_name` does not already exist. Idempotent.
85        AddOptionalString {
86            canonical_name: String,
87            api_name: String,
88            description: String,
89        },
90        /// Remove an attribute by canonical name. No-op if absent.
91        RemoveAttribute(String),
92    }
93
94    impl ResourceOp {
95        fn apply_to(&self, mut r: IacResource) -> IacResource {
96            match self {
97                Self::SetDescription(d) => {
98                    r.description = d.clone();
99                }
100                Self::SetCategory(c) => {
101                    r.category = c.clone();
102                }
103                Self::MarkSensitive(name) => {
104                    for a in &mut r.attributes {
105                        if a.canonical_name == *name {
106                            a.sensitive = true;
107                        }
108                    }
109                }
110                Self::AddOptionalString {
111                    canonical_name,
112                    api_name,
113                    description,
114                } => {
115                    let already = r
116                        .attributes
117                        .iter()
118                        .any(|a| a.canonical_name == *canonical_name);
119                    if !already {
120                        r.attributes.push(IacAttribute {
121                            api_name: api_name.clone(),
122                            canonical_name: canonical_name.clone(),
123                            description: description.clone(),
124                            iac_type: IacType::String,
125                            optional: true,
126                            ..Default::default()
127                        });
128                    }
129                }
130                Self::RemoveAttribute(name) => {
131                    r.attributes.retain(|a| a.canonical_name != *name);
132                }
133            }
134            r
135        }
136    }
137
138    /// A sequence of ops runs in order and is itself a `Transform`.
139    impl Transform<IacResource> for Vec<ResourceOp> {
140        fn name(&self) -> &'static str {
141            "ResourceOpSeq"
142        }
143        fn apply(&self, mut r: IacResource) -> IacResource {
144            for op in self {
145                r = op.apply_to(r);
146            }
147            r
148        }
149    }
150
151    /// A single op is also a `Transform`.
152    impl Transform<IacResource> for ResourceOp {
153        fn name(&self) -> &'static str {
154            match self {
155                Self::SetDescription(_) => "SetDescription",
156                Self::SetCategory(_) => "SetCategory",
157                Self::MarkSensitive(_) => "MarkSensitive",
158                Self::AddOptionalString { .. } => "AddOptionalString",
159                Self::RemoveAttribute(_) => "RemoveAttribute",
160            }
161        }
162        fn apply(&self, r: IacResource) -> IacResource {
163            self.apply_to(r)
164        }
165    }
166}
167
168/// Minimal s-expression reader → `Vec<ResourceOp>`.
169///
170/// The surface is intentionally tiny: one op per top-level form, all
171/// args are strings (single- or double-quoted). Supports the op names
172/// `set-description`, `set-category`, `mark-sensitive`,
173/// `add-optional-string`, `remove-attribute`. Whitespace and `;`-line
174/// comments are tolerated.
175///
176/// ```text
177/// ; add a tag field; mark the secret field sensitive
178/// (add-optional-string "tag" "tag" "a user-provided tag")
179/// (mark-sensitive "value")
180/// ```
181///
182/// The parser is a single-pass hand-rolled reader — we are explicitly
183/// not pulling in `serde_lexpr` or `steel` here. The point is to prove
184/// the extension surface works end-to-end; a full Scheme runtime can
185/// slot in later behind the same `Transform` trait.
186pub mod script {
187    use super::ops::ResourceOp;
188
189    /// Parse a script source into a list of ops.
190    ///
191    /// # Errors
192    /// Returns `Err(String)` describing the first parse failure. The
193    /// string is human-readable and includes a pointer to the position.
194    pub fn parse(source: &str) -> Result<Vec<ResourceOp>, String> {
195        let mut chars = source.chars().peekable();
196        let mut ops = Vec::new();
197        loop {
198            skip_ws_and_comments(&mut chars);
199            if chars.peek().is_none() {
200                break;
201            }
202            let expr = read_list(&mut chars)?;
203            ops.push(expr_to_op(&expr)?);
204        }
205        Ok(ops)
206    }
207
208    // ── Minimal s-expr value ────────────────────────────────────────
209
210    #[derive(Debug, Clone, PartialEq, Eq)]
211    enum Sexpr {
212        Sym(String),
213        Str(String),
214        List(Vec<Sexpr>),
215    }
216
217    fn skip_ws_and_comments(chars: &mut std::iter::Peekable<std::str::Chars>) {
218        loop {
219            match chars.peek().copied() {
220                Some(c) if c.is_whitespace() => {
221                    chars.next();
222                }
223                Some(';') => {
224                    for c in chars.by_ref() {
225                        if c == '\n' {
226                            break;
227                        }
228                    }
229                }
230                _ => break,
231            }
232        }
233    }
234
235    fn read_list(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<Sexpr, String> {
236        skip_ws_and_comments(chars);
237        match chars.next() {
238            Some('(') => {
239                let mut items = Vec::new();
240                loop {
241                    skip_ws_and_comments(chars);
242                    match chars.peek().copied() {
243                        None => return Err("unterminated list".into()),
244                        Some(')') => {
245                            chars.next();
246                            return Ok(Sexpr::List(items));
247                        }
248                        Some('(') => items.push(read_list(chars)?),
249                        Some('"') => items.push(read_string(chars)?),
250                        Some(_) => items.push(read_atom(chars)?),
251                    }
252                }
253            }
254            Some(c) => Err(format!("expected '(', got '{c}'")),
255            None => Err("expected '(', got EOF".into()),
256        }
257    }
258
259    fn read_string(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<Sexpr, String> {
260        if chars.next() != Some('"') {
261            return Err("expected opening quote".into());
262        }
263        let mut buf = String::new();
264        loop {
265            match chars.next() {
266                None => return Err("unterminated string".into()),
267                Some('"') => return Ok(Sexpr::Str(buf)),
268                Some('\\') => match chars.next() {
269                    Some('n') => buf.push('\n'),
270                    Some('t') => buf.push('\t'),
271                    Some('"') => buf.push('"'),
272                    Some('\\') => buf.push('\\'),
273                    Some(c) => {
274                        return Err(format!("unknown escape \\{c}"));
275                    }
276                    None => return Err("dangling \\ at EOF".into()),
277                },
278                Some(c) => buf.push(c),
279            }
280        }
281    }
282
283    fn read_atom(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<Sexpr, String> {
284        let mut buf = String::new();
285        while let Some(&c) = chars.peek() {
286            if c.is_whitespace() || c == '(' || c == ')' || c == ';' {
287                break;
288            }
289            buf.push(c);
290            chars.next();
291        }
292        if buf.is_empty() {
293            Err("empty atom".into())
294        } else {
295            Ok(Sexpr::Sym(buf))
296        }
297    }
298
299    fn expr_to_op(expr: &Sexpr) -> Result<ResourceOp, String> {
300        let Sexpr::List(items) = expr else {
301            return Err("top-level form must be a list".into());
302        };
303        let (head, rest) = items
304            .split_first()
305            .ok_or_else(|| "empty top-level form".to_string())?;
306        let name = match head {
307            Sexpr::Sym(s) => s,
308            _ => return Err("first element must be an op name symbol".into()),
309        };
310        let args: Vec<&str> = rest
311            .iter()
312            .map(|e| match e {
313                Sexpr::Str(s) | Sexpr::Sym(s) => Ok(s.as_str()),
314                Sexpr::List(_) => Err("nested lists not supported in args".to_string()),
315            })
316            .collect::<Result<_, _>>()?;
317
318        match name.as_str() {
319            "set-description" => {
320                if args.len() != 1 {
321                    return Err(format!(
322                        "set-description: expected 1 arg, got {}",
323                        args.len()
324                    ));
325                }
326                Ok(ResourceOp::SetDescription(args[0].to_string()))
327            }
328            "set-category" => {
329                if args.len() != 1 {
330                    return Err(format!("set-category: expected 1 arg, got {}", args.len()));
331                }
332                Ok(ResourceOp::SetCategory(args[0].to_string()))
333            }
334            "mark-sensitive" => {
335                if args.len() != 1 {
336                    return Err(format!(
337                        "mark-sensitive: expected 1 arg, got {}",
338                        args.len()
339                    ));
340                }
341                Ok(ResourceOp::MarkSensitive(args[0].to_string()))
342            }
343            "add-optional-string" => {
344                if args.len() != 3 {
345                    return Err(format!(
346                        "add-optional-string: expected 3 args, got {}",
347                        args.len()
348                    ));
349                }
350                Ok(ResourceOp::AddOptionalString {
351                    canonical_name: args[0].to_string(),
352                    api_name: args[1].to_string(),
353                    description: args[2].to_string(),
354                })
355            }
356            "remove-attribute" => {
357                if args.len() != 1 {
358                    return Err(format!(
359                        "remove-attribute: expected 1 arg, got {}",
360                        args.len()
361                    ));
362                }
363                Ok(ResourceOp::RemoveAttribute(args[0].to_string()))
364            }
365            other => Err(format!("unknown op: {other}")),
366        }
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::ops::ResourceOp;
373    use super::script;
374    use super::{ComposeTransforms, Identity, Transform};
375    use crate::ir::IacType;
376    use crate::testing::{TestAttributeBuilder, test_resource};
377
378    fn res() -> crate::ir::IacResource {
379        let mut r = test_resource("widget");
380        r.attributes = vec![
381            TestAttributeBuilder::new("name", IacType::String)
382                .required()
383                .build(),
384            TestAttributeBuilder::new("value", IacType::String).build(),
385        ];
386        r.description = "initial".into();
387        r
388    }
389
390    #[test]
391    fn identity_leaves_resource_unchanged() {
392        let r = res();
393        let r2 = Identity.apply(r.clone());
394        assert_eq!(r.name, r2.name);
395        assert_eq!(r.description, r2.description);
396        assert_eq!(r.attributes.len(), r2.attributes.len());
397    }
398
399    #[test]
400    fn set_description_op_runs() {
401        let r = res();
402        let r = ResourceOp::SetDescription("final".into()).apply(r);
403        assert_eq!(r.description, "final");
404    }
405
406    #[test]
407    fn mark_sensitive_flips_the_flag() {
408        let r = res();
409        let r = ResourceOp::MarkSensitive("value".into()).apply(r);
410        assert!(
411            r.attributes
412                .iter()
413                .any(|a| a.canonical_name == "value" && a.sensitive)
414        );
415    }
416
417    #[test]
418    fn add_optional_string_appends_new_attribute() {
419        let r = res();
420        let before = r.attributes.len();
421        let r = ResourceOp::AddOptionalString {
422            canonical_name: "note".into(),
423            api_name: "note".into(),
424            description: "a note".into(),
425        }
426        .apply(r);
427        assert_eq!(r.attributes.len(), before + 1);
428        assert!(r.attributes.iter().any(|a| a.canonical_name == "note"));
429    }
430
431    #[test]
432    fn add_optional_string_is_idempotent() {
433        let r = res();
434        let op = ResourceOp::AddOptionalString {
435            canonical_name: "note".into(),
436            api_name: "note".into(),
437            description: "a note".into(),
438        };
439        let r1 = op.apply(r);
440        let len1 = r1.attributes.len();
441        let r2 = op.apply(r1);
442        assert_eq!(r2.attributes.len(), len1);
443    }
444
445    #[test]
446    fn remove_attribute_drops_by_canonical_name() {
447        let r = res();
448        let r = ResourceOp::RemoveAttribute("value".into()).apply(r);
449        assert!(!r.attributes.iter().any(|a| a.canonical_name == "value"));
450    }
451
452    #[test]
453    fn vec_of_ops_is_a_transform() {
454        let r = res();
455        let ops: Vec<ResourceOp> = vec![
456            ResourceOp::SetDescription("tuned".into()),
457            ResourceOp::MarkSensitive("value".into()),
458        ];
459        let r = ops.apply(r);
460        assert_eq!(r.description, "tuned");
461        assert!(
462            r.attributes
463                .iter()
464                .any(|a| a.canonical_name == "value" && a.sensitive)
465        );
466    }
467
468    #[test]
469    fn compose_transforms_applies_in_order() {
470        let r = res();
471        let a = ResourceOp::SetDescription("step1".into());
472        let b = ResourceOp::SetDescription("step2".into());
473        let composed = ComposeTransforms(a, b);
474        let r = composed.apply(r);
475        assert_eq!(r.description, "step2", "second transform wins");
476    }
477
478    #[test]
479    fn compose_with_identity_left_and_right() {
480        let r = res();
481        let original_desc = r.description.clone();
482        let with_id_left =
483            ComposeTransforms(Identity, ResourceOp::SetDescription("x".into())).apply(r.clone());
484        let with_id_right =
485            ComposeTransforms(ResourceOp::SetDescription("x".into()), Identity).apply(r);
486        assert_eq!(with_id_left.description, "x");
487        assert_eq!(with_id_right.description, "x");
488        assert_ne!(original_desc, "x");
489    }
490
491    // ── Script layer ───────────────────────────────────────────────
492
493    #[test]
494    fn script_parses_a_single_op() {
495        let ops = script::parse(r#"(set-description "hello")"#).expect("parse");
496        assert_eq!(ops, vec![ResourceOp::SetDescription("hello".into())]);
497    }
498
499    #[test]
500    fn script_parses_multiple_ops() {
501        let src = r#"
502            ; add a field and mark value sensitive
503            (add-optional-string "note" "note" "a free-form note")
504            (mark-sensitive "value")
505        "#;
506        let ops = script::parse(src).expect("parse");
507        assert_eq!(ops.len(), 2);
508    }
509
510    #[test]
511    fn script_tolerates_comments_and_blank_lines() {
512        let src = r#"
513            ;; this is a comment
514
515            (set-category "tuned")
516
517            ; another comment
518            (set-description "v2")
519        "#;
520        let ops = script::parse(src).expect("parse");
521        assert_eq!(ops.len(), 2);
522        assert_eq!(ops[0], ResourceOp::SetCategory("tuned".into()));
523    }
524
525    #[test]
526    fn script_rejects_unknown_op() {
527        let err = script::parse(r#"(unknown-thing "x")"#).unwrap_err();
528        assert!(err.contains("unknown op"));
529    }
530
531    #[test]
532    fn script_rejects_arity_mismatch() {
533        let err = script::parse(r#"(set-description "a" "b")"#).unwrap_err();
534        assert!(err.contains("set-description"));
535        assert!(err.contains("expected 1"));
536    }
537
538    #[test]
539    fn script_end_to_end_over_resource() {
540        let src = r#"
541            (set-description "v2")
542            (add-optional-string "note" "note" "a note")
543            (mark-sensitive "value")
544        "#;
545        let ops = script::parse(src).expect("parse");
546        let r = ops.apply(res());
547        assert_eq!(r.description, "v2");
548        assert!(r.attributes.iter().any(|a| a.canonical_name == "note"));
549        assert!(
550            r.attributes
551                .iter()
552                .any(|a| a.canonical_name == "value" && a.sensitive)
553        );
554    }
555
556    #[test]
557    fn script_parse_is_deterministic() {
558        let src = r#"(set-description "x") (mark-sensitive "y")"#;
559        let a = script::parse(src).unwrap();
560        let b = script::parse(src).unwrap();
561        assert_eq!(a, b);
562    }
563
564    #[test]
565    fn script_handles_string_escapes() {
566        let ops = script::parse(r#"(set-description "line1\nline2")"#).unwrap();
567        assert_eq!(ops, vec![ResourceOp::SetDescription("line1\nline2".into())]);
568    }
569
570    #[test]
571    fn script_rejects_unterminated_string() {
572        let err = script::parse(r#"(set-description "hello"#).unwrap_err();
573        assert!(err.contains("unterminated string"));
574    }
575
576    #[test]
577    fn script_rejects_unterminated_list() {
578        let err = script::parse(r#"(set-description "hello""#).unwrap_err();
579        assert!(err.contains("unterminated list"));
580    }
581}