1use crate::ir::IacResource;
26
27pub trait Transform<T> {
29 fn name(&self) -> &'static str;
31
32 fn apply(&self, value: T) -> T;
34}
35
36#[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
49pub 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
65pub mod ops {
70 use super::{IacResource, Transform};
71 use crate::ir::{IacAttribute, IacType};
72
73 #[derive(Debug, Clone, PartialEq, Eq)]
75 #[non_exhaustive]
76 pub enum ResourceOp {
77 SetDescription(String),
79 SetCategory(String),
81 MarkSensitive(String),
83 AddOptionalString {
86 canonical_name: String,
87 api_name: String,
88 description: String,
89 },
90 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 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 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
168pub mod script {
187 use super::ops::ResourceOp;
188
189 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 #[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 #[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}