Skip to main content

alembic_engine/
retort.rs

1//! retort mapping: compile raw yaml into canonical ir.
2
3use alembic_core::{key_string, uid_v5, Inventory, JsonMap, Key, Object, Schema, TypeName, Uid};
4use anyhow::{anyhow, Context, Result};
5use serde::Deserialize;
6use serde_json::{Map as JsonObject, Value as JsonValue};
7use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
8use std::collections::BTreeMap;
9use std::path::Path;
10use uuid::Uuid;
11
12#[derive(Debug, Deserialize)]
13pub struct Retort {
14    pub version: u32,
15    pub schema: Schema,
16    #[serde(default)]
17    pub rules: Vec<Rule>,
18}
19
20#[derive(Debug, Deserialize)]
21pub struct Rule {
22    pub name: String,
23    pub select: String,
24    /// Rule-level vars extracted once and shared by all emits.
25    #[serde(default)]
26    pub vars: BTreeMap<String, VarSpec>,
27    /// Named UIDs computed once and available as `${uids.name}` in emits.
28    #[serde(default)]
29    pub uids: BTreeMap<String, EmitUid>,
30    /// Single emit (backward compat) or list of emits.
31    pub emit: EmitSpec,
32}
33
34/// Either a single emit (backward compatible) or a list of emits.
35#[derive(Debug, Deserialize)]
36#[serde(untagged)]
37pub enum EmitSpec {
38    Single(Emit),
39    Multi(Vec<Emit>),
40}
41
42#[derive(Debug, Deserialize)]
43pub struct Emit {
44    #[serde(rename = "type", alias = "kind")]
45    pub type_name: String,
46    pub key: BTreeMap<String, YamlValue>,
47    #[serde(default)]
48    pub uid: Option<EmitUid>,
49    #[serde(default)]
50    pub vars: BTreeMap<String, VarSpec>,
51    #[serde(default)]
52    pub attrs: BTreeMap<String, YamlValue>,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct VarSpec {
57    pub from: String,
58    #[serde(default)]
59    pub required: bool,
60}
61
62#[derive(Debug, Deserialize)]
63#[serde(untagged)]
64pub enum EmitUid {
65    V5 { v5: UidV5Spec },
66    Template(String),
67}
68
69#[derive(Debug, Deserialize)]
70pub struct UidV5Spec {
71    #[serde(rename = "type", alias = "kind")]
72    pub type_name: String,
73    pub stable: String,
74}
75
76#[derive(Debug, Clone)]
77enum SelectorToken {
78    Key(String),
79    Index(usize),
80    Wildcard,
81}
82
83#[derive(Debug, Clone)]
84enum PathToken {
85    Key(String),
86    Index(usize),
87}
88
89#[derive(Debug)]
90struct RelativePath {
91    up: usize,
92    selectors: Vec<SelectorToken>,
93}
94
95pub fn load_retort(path: impl AsRef<Path>) -> Result<Retort> {
96    let path = path.as_ref();
97    let raw = std::fs::read_to_string(path)
98        .with_context(|| format!("read retort: {}", path.display()))?;
99    let retort: Retort =
100        serde_yaml::from_str(&raw).with_context(|| format!("parse retort: {}", path.display()))?;
101    Ok(retort)
102}
103
104pub fn load_raw_yaml(path: impl AsRef<Path>) -> Result<YamlValue> {
105    let path = path.as_ref();
106    let raw = std::fs::read_to_string(path)
107        .with_context(|| format!("read raw yaml: {}", path.display()))?;
108    let value: YamlValue =
109        serde_yaml::from_str(&raw).with_context(|| format!("parse yaml: {}", path.display()))?;
110    Ok(value)
111}
112
113pub fn is_brew_format(raw: &YamlValue) -> bool {
114    let YamlValue::Mapping(map) = raw else {
115        return false;
116    };
117    map.contains_key(YamlValue::String("objects".to_string()))
118}
119
120pub fn compile_retort(raw: &YamlValue, retort: &Retort) -> Result<Inventory> {
121    if retort.version != 1 {
122        return Err(anyhow!("retort version {} is unsupported", retort.version));
123    }
124
125    let mut objects = Vec::new();
126
127    for rule in &retort.rules {
128        let selectors = parse_selector_path(&rule.select)
129            .with_context(|| format!("rule {}: invalid select path", rule.name))?;
130        let mut selected = Vec::new();
131        select_paths(raw, &selectors, &mut Vec::new(), &mut selected);
132
133        let emits = match &rule.emit {
134            EmitSpec::Single(emit) => vec![emit],
135            EmitSpec::Multi(emits) => emits.iter().collect(),
136        };
137
138        for path in selected {
139            // Extract rule-level vars first (shared by all emits).
140            let mut vars = extract_vars(raw, &path, &rule.vars, &rule.name)?;
141
142            // Compute named UIDs and add them as uids.X vars.
143            for (uid_name, uid_spec) in &rule.uids {
144                let uid = resolve_named_uid(uid_spec, &vars, &rule.name, uid_name)?;
145                vars.insert(
146                    format!("uids.{}", uid_name),
147                    JsonValue::String(uid.to_string()),
148                );
149            }
150
151            // Process each emit.
152            for emit in &emits {
153                // Merge emit-level vars with rule-level vars (emit takes precedence).
154                let mut emit_vars = vars.clone();
155                let emit_specific_vars = extract_vars(raw, &path, &emit.vars, &rule.name)?;
156                emit_vars.extend(emit_specific_vars);
157
158                let key = render_key(&emit.key, &emit_vars, &rule.name)?;
159                let uid = resolve_emit_uid(emit, &emit_vars, &rule.name, &key)?;
160                let type_name = TypeName::new(render_template(
161                    &emit.type_name,
162                    &emit_vars,
163                    &rule.name,
164                    "type",
165                )?);
166                let attrs = render_attrs(&emit.attrs, &emit_vars, &rule.name, "attrs")?;
167                let object = build_object(uid, type_name, key, attrs)?;
168                objects.push(object);
169            }
170        }
171    }
172
173    objects.sort_by(|a, b| {
174        inventory_sort_key(&a.type_name, &a.key).cmp(&inventory_sort_key(&b.type_name, &b.key))
175    });
176
177    let inventory = Inventory {
178        schema: retort.schema.clone(),
179        objects,
180    };
181    crate::report_to_result(crate::validate(&inventory))?;
182    Ok(inventory)
183}
184
185fn build_object(
186    uid: Uid,
187    type_name: TypeName,
188    key: Key,
189    attrs: JsonObject<String, JsonValue>,
190) -> Result<Object> {
191    let attrs_value = JsonValue::Object(attrs);
192    let attrs = to_object_map(attrs_value)?;
193    Ok(Object::new(uid, type_name, key, attrs)?)
194}
195
196fn to_object_map(value: JsonValue) -> Result<JsonMap> {
197    match value {
198        JsonValue::Object(map) => Ok(map.into_iter().collect::<BTreeMap<_, _>>().into()),
199        _ => Err(anyhow!("attrs must be an object")),
200    }
201}
202
203fn extract_vars(
204    raw: &YamlValue,
205    path: &[PathToken],
206    specs: &BTreeMap<String, VarSpec>,
207    rule: &str,
208) -> Result<BTreeMap<String, JsonValue>> {
209    let mut vars = BTreeMap::new();
210    for (name, spec) in specs {
211        let rel = parse_relative_path(&spec.from)
212            .with_context(|| format!("rule {rule}: invalid var path for {name}: {}", spec.from))?;
213        let values = extract_values(raw, path, &rel)?;
214        if values.is_empty() {
215            if spec.required {
216                return Err(anyhow!(
217                    "rule {rule}: missing required var {name} from {}",
218                    spec.from
219                ));
220            }
221            continue;
222        }
223        let json_value = if values.len() == 1 {
224            yaml_to_json(values[0].clone())?
225        } else {
226            let mut items = Vec::new();
227            for value in values {
228                items.push(yaml_to_json(value.clone())?);
229            }
230            JsonValue::Array(items)
231        };
232        vars.insert(name.clone(), json_value);
233    }
234    Ok(vars)
235}
236
237fn resolve_emit_uid(
238    emit: &Emit,
239    vars: &BTreeMap<String, JsonValue>,
240    rule: &str,
241    key: &Key,
242) -> Result<Uid> {
243    match emit.uid.as_ref() {
244        Some(EmitUid::V5 { v5 }) => resolve_uid_v5(v5, vars, rule, "uid"),
245        Some(EmitUid::Template(template)) => resolve_uid_template(template, vars, rule),
246        None => Ok(uid_v5(&emit.type_name, &key_string(key))),
247    }
248}
249
250fn resolve_named_uid(
251    uid_spec: &EmitUid,
252    vars: &BTreeMap<String, JsonValue>,
253    rule: &str,
254    uid_name: &str,
255) -> Result<Uid> {
256    let context = format!("uids.{}", uid_name);
257    match uid_spec {
258        EmitUid::V5 { v5 } => resolve_uid_v5(v5, vars, rule, &context),
259        EmitUid::Template(template) => resolve_uid_template(template, vars, rule),
260    }
261}
262
263fn resolve_uid_v5(
264    spec: &UidV5Spec,
265    vars: &BTreeMap<String, JsonValue>,
266    rule: &str,
267    context: &str,
268) -> Result<Uid> {
269    let kind = render_template(&spec.type_name, vars, rule, context)?;
270    let stable = render_template(&spec.stable, vars, rule, context)?;
271    if kind.trim().is_empty() || stable.trim().is_empty() {
272        return Err(anyhow!(
273            "rule {rule}: uid v5 requires non-empty type and stable values"
274        ));
275    }
276    Ok(uid_v5(&kind, &stable))
277}
278
279fn resolve_uid_template(
280    template: &str,
281    vars: &BTreeMap<String, JsonValue>,
282    rule: &str,
283) -> Result<Uid> {
284    let rendered = render_template(template, vars, rule, "uid")?;
285    let parsed = Uuid::parse_str(&rendered)
286        .with_context(|| format!("rule {rule}: uid template is not a valid uuid: {rendered}"))?;
287    Ok(parsed)
288}
289
290fn render_attrs(
291    attrs: &BTreeMap<String, YamlValue>,
292    vars: &BTreeMap<String, JsonValue>,
293    rule: &str,
294    context: &str,
295) -> Result<JsonObject<String, JsonValue>> {
296    let mut map = JsonObject::new();
297    for (key, value) in attrs {
298        let rendered = render_yaml_value(value, vars, rule, context, false)?;
299        if let Some(value) = rendered {
300            map.insert(key.clone(), value);
301        }
302    }
303    Ok(map)
304}
305
306fn render_key(
307    key: &BTreeMap<String, YamlValue>,
308    vars: &BTreeMap<String, JsonValue>,
309    rule: &str,
310) -> Result<Key> {
311    let mut map = BTreeMap::new();
312    for (field, value) in key {
313        let context = format!("key.{field}");
314        let rendered = render_yaml_value(value, vars, rule, &context, false)?;
315        let Some(value) = rendered else {
316            return Err(anyhow!("rule {rule}: missing value for {context}"));
317        };
318        map.insert(field.clone(), value);
319    }
320    Ok(Key::from(map))
321}
322
323fn render_yaml_value(
324    value: &YamlValue,
325    vars: &BTreeMap<String, JsonValue>,
326    rule: &str,
327    context: &str,
328    allow_missing: bool,
329) -> Result<Option<JsonValue>> {
330    match value {
331        YamlValue::String(raw) => render_string_value(raw, vars, rule, context, allow_missing),
332        YamlValue::Sequence(items) => {
333            let mut rendered = Vec::new();
334            for item in items {
335                let value = render_yaml_value(item, vars, rule, context, allow_missing)?;
336                match value {
337                    Some(value) => rendered.push(value),
338                    None => {
339                        if allow_missing {
340                            return Ok(None);
341                        }
342                        return Err(anyhow!("rule {rule}: missing value in {context}"));
343                    }
344                }
345            }
346            Ok(Some(JsonValue::Array(rendered)))
347        }
348        YamlValue::Mapping(map) => {
349            if let Some((optional, spec)) = parse_uid_mapping(map) {
350                return render_uid_mapping(&spec, vars, rule, context, optional);
351            }
352
353            let mut rendered = JsonObject::new();
354            for (key, value) in map {
355                let key = key
356                    .as_str()
357                    .ok_or_else(|| anyhow!("rule {rule}: {context} keys must be strings"))?
358                    .to_string();
359                let value = render_yaml_value(value, vars, rule, context, allow_missing)?;
360                match value {
361                    Some(value) => {
362                        rendered.insert(key, value);
363                    }
364                    None => {
365                        if allow_missing {
366                            return Ok(None);
367                        }
368                        return Err(anyhow!("rule {rule}: missing value in {context}"));
369                    }
370                }
371            }
372            Ok(Some(JsonValue::Object(rendered)))
373        }
374        _ => Ok(Some(yaml_to_json(value.clone())?)),
375    }
376}
377
378fn render_uid_mapping(
379    spec: &UidV5Spec,
380    vars: &BTreeMap<String, JsonValue>,
381    rule: &str,
382    context: &str,
383    optional: bool,
384) -> Result<Option<JsonValue>> {
385    let kind = render_template_optional(&spec.type_name, vars, rule, context, optional)?;
386    let stable = render_template_optional(&spec.stable, vars, rule, context, optional)?;
387    let (Some(kind), Some(stable)) = (kind, stable) else {
388        return Ok(None);
389    };
390    if kind.trim().is_empty() || stable.trim().is_empty() {
391        if optional {
392            return Ok(None);
393        }
394        return Err(anyhow!(
395            "rule {rule}: uid mapping requires non-empty type and stable"
396        ));
397    }
398    let uid = uid_v5(&kind, &stable);
399    Ok(Some(JsonValue::String(uid.to_string())))
400}
401
402fn render_string_value(
403    raw: &str,
404    vars: &BTreeMap<String, JsonValue>,
405    rule: &str,
406    context: &str,
407    allow_missing: bool,
408) -> Result<Option<JsonValue>> {
409    if let Some(var) = placeholder_only(raw) {
410        if let Some(value) = vars.get(var) {
411            if value.is_null() && allow_missing {
412                return Ok(None);
413            }
414            return Ok(Some(value.clone()));
415        }
416        if allow_missing {
417            return Ok(None);
418        }
419        return Err(anyhow!("rule {rule}: missing var {var} in {context}"));
420    }
421
422    if raw.contains("${") {
423        let rendered = render_template_optional(raw, vars, rule, context, allow_missing)?;
424        return Ok(rendered.map(JsonValue::String));
425    }
426
427    Ok(Some(JsonValue::String(raw.to_string())))
428}
429
430fn render_template(
431    template: &str,
432    vars: &BTreeMap<String, JsonValue>,
433    rule: &str,
434    context: &str,
435) -> Result<String> {
436    render_template_optional(template, vars, rule, context, false)?
437        .ok_or_else(|| anyhow!("rule {rule}: missing vars for template {template}"))
438}
439
440fn render_template_optional(
441    template: &str,
442    vars: &BTreeMap<String, JsonValue>,
443    rule: &str,
444    context: &str,
445    allow_missing: bool,
446) -> Result<Option<String>> {
447    let mut rendered = String::new();
448    let mut rest = template;
449
450    while let Some(start) = rest.find("${") {
451        rendered.push_str(&rest[..start]);
452        let after = &rest[start + 2..];
453        let Some(end) = after.find('}') else {
454            return Err(anyhow!(
455                "rule {rule}: unterminated template in {context}: {template}"
456            ));
457        };
458        let name = &after[..end];
459        let value = vars.get(name);
460        let Some(value) = value else {
461            if allow_missing {
462                return Ok(None);
463            }
464            return Err(anyhow!("rule {rule}: missing var {name} in {context}"));
465        };
466        if value.is_null() && allow_missing {
467            return Ok(None);
468        }
469        let Some(value) = value.as_str() else {
470            return Err(anyhow!(
471                "rule {rule}: var {name} in {context} must be a string"
472            ));
473        };
474        rendered.push_str(value);
475        rest = &after[end + 1..];
476    }
477    rendered.push_str(rest);
478    Ok(Some(rendered))
479}
480
481fn placeholder_only(input: &str) -> Option<&str> {
482    if !input.starts_with("${") || !input.ends_with('}') {
483        return None;
484    }
485    let inner = &input[2..input.len() - 1];
486    if inner.contains("${") || inner.contains('}') || inner.is_empty() {
487        return None;
488    }
489    Some(inner)
490}
491
492fn parse_uid_mapping(map: &YamlMapping) -> Option<(bool, UidV5Spec)> {
493    if map.len() != 1 {
494        return None;
495    }
496    let (key, value) = map.iter().next()?;
497    let key = key.as_str()?;
498    let optional = match key {
499        "uid" => false,
500        "uid?" => true,
501        _ => return None,
502    };
503    let YamlValue::Mapping(inner) = value else {
504        return None;
505    };
506    let kind = inner
507        .get(YamlValue::String("type".to_string()))
508        .or_else(|| inner.get(YamlValue::String("kind".to_string())))?;
509    let stable = inner.get(YamlValue::String("stable".to_string()))?;
510    let kind = kind.as_str()?.to_string();
511    let stable = stable.as_str()?.to_string();
512    Some((
513        optional,
514        UidV5Spec {
515            type_name: kind,
516            stable,
517        },
518    ))
519}
520
521fn parse_selector_path(path: &str) -> Result<Vec<SelectorToken>> {
522    if !path.starts_with('/') {
523        return Err(anyhow!("select path must start with '/'"));
524    }
525    let mut tokens = Vec::new();
526    for segment in path.trim_start_matches('/').split('/') {
527        if segment.is_empty() {
528            continue;
529        }
530        tokens.push(parse_selector_segment(segment)?);
531    }
532    Ok(tokens)
533}
534
535fn parse_selector_segment(segment: &str) -> Result<SelectorToken> {
536    if segment == "*" {
537        return Ok(SelectorToken::Wildcard);
538    }
539    if let Ok(index) = segment.parse::<usize>() {
540        return Ok(SelectorToken::Index(index));
541    }
542    Ok(SelectorToken::Key(segment.to_string()))
543}
544
545fn parse_relative_path(path: &str) -> Result<RelativePath> {
546    let mut rest = path.trim();
547    let mut up = 0;
548    while rest.starts_with('^') {
549        up += 1;
550        rest = &rest[1..];
551        if rest.starts_with('.') {
552            rest = &rest[1..];
553        }
554    }
555    if rest.starts_with('.') {
556        rest = &rest[1..];
557    }
558    if rest.starts_with('/') {
559        rest = &rest[1..];
560    }
561    let selectors = if rest.is_empty() {
562        Vec::new()
563    } else {
564        rest.split('/')
565            .filter(|s| !s.is_empty())
566            .map(parse_selector_segment)
567            .collect::<Result<Vec<_>>>()?
568    };
569    Ok(RelativePath { up, selectors })
570}
571
572fn select_paths(
573    value: &YamlValue,
574    selectors: &[SelectorToken],
575    current_path: &mut Vec<PathToken>,
576    results: &mut Vec<Vec<PathToken>>,
577) {
578    if selectors.is_empty() {
579        results.push(current_path.clone());
580        return;
581    }
582
583    match selectors[0].clone() {
584        SelectorToken::Key(key) => {
585            if let YamlValue::Mapping(map) = value {
586                if let Some(value) = map.get(YamlValue::String(key.clone())) {
587                    current_path.push(PathToken::Key(key));
588                    select_paths(value, &selectors[1..], current_path, results);
589                    current_path.pop();
590                }
591            }
592        }
593        SelectorToken::Index(index) => {
594            if let YamlValue::Sequence(items) = value {
595                if let Some(value) = items.get(index) {
596                    current_path.push(PathToken::Index(index));
597                    select_paths(value, &selectors[1..], current_path, results);
598                    current_path.pop();
599                }
600            }
601        }
602        SelectorToken::Wildcard => match value {
603            YamlValue::Sequence(items) => {
604                for (index, value) in items.iter().enumerate() {
605                    current_path.push(PathToken::Index(index));
606                    select_paths(value, &selectors[1..], current_path, results);
607                    current_path.pop();
608                }
609            }
610            YamlValue::Mapping(map) => {
611                for (key, value) in map {
612                    let Some(key) = key.as_str() else {
613                        continue;
614                    };
615                    current_path.push(PathToken::Key(key.to_string()));
616                    select_paths(value, &selectors[1..], current_path, results);
617                    current_path.pop();
618                }
619            }
620            _ => {}
621        },
622    }
623}
624
625fn extract_values<'a>(
626    raw: &'a YamlValue,
627    path: &[PathToken],
628    rel: &RelativePath,
629) -> Result<Vec<&'a YamlValue>> {
630    let base_path = ancestor_path(raw, path, rel.up)?;
631    let Some(base_value) = value_at_path(raw, &base_path) else {
632        return Ok(Vec::new());
633    };
634    let mut results = Vec::new();
635    select_values(base_value, &rel.selectors, &mut results);
636    Ok(results)
637}
638
639fn ancestor_path(raw: &YamlValue, path: &[PathToken], up: usize) -> Result<Vec<PathToken>> {
640    let mut current: Vec<PathToken> = path.to_vec();
641    for _ in 0..up {
642        if current.is_empty() {
643            return Err(anyhow!("relative path escapes above root"));
644        }
645        current.pop();
646        loop {
647            let Some(value) = value_at_path(raw, &current) else {
648                break;
649            };
650            if matches!(value, YamlValue::Sequence(_)) {
651                if current.is_empty() {
652                    break;
653                }
654                current.pop();
655            } else {
656                break;
657            }
658        }
659    }
660    Ok(current)
661}
662
663fn value_at_path<'a>(value: &'a YamlValue, path: &[PathToken]) -> Option<&'a YamlValue> {
664    let mut current = value;
665    for token in path {
666        match token {
667            PathToken::Key(key) => {
668                let YamlValue::Mapping(map) = current else {
669                    return None;
670                };
671                current = map.get(YamlValue::String(key.clone()))?;
672            }
673            PathToken::Index(index) => {
674                let YamlValue::Sequence(items) = current else {
675                    return None;
676                };
677                current = items.get(*index)?;
678            }
679        }
680    }
681    Some(current)
682}
683
684fn select_values<'a>(
685    value: &'a YamlValue,
686    selectors: &[SelectorToken],
687    results: &mut Vec<&'a YamlValue>,
688) {
689    if selectors.is_empty() {
690        results.push(value);
691        return;
692    }
693    match selectors[0].clone() {
694        SelectorToken::Key(key) => {
695            if let YamlValue::Mapping(map) = value {
696                if let Some(value) = map.get(YamlValue::String(key)) {
697                    select_values(value, &selectors[1..], results);
698                }
699            }
700        }
701        SelectorToken::Index(index) => {
702            if let YamlValue::Sequence(items) = value {
703                if let Some(value) = items.get(index) {
704                    select_values(value, &selectors[1..], results);
705                }
706            }
707        }
708        SelectorToken::Wildcard => match value {
709            YamlValue::Sequence(items) => {
710                for value in items {
711                    select_values(value, &selectors[1..], results);
712                }
713            }
714            YamlValue::Mapping(map) => {
715                for (key, value) in map {
716                    if key.as_str().is_none() {
717                        continue;
718                    }
719                    select_values(value, &selectors[1..], results);
720                }
721            }
722            _ => {}
723        },
724    }
725}
726
727fn yaml_to_json(value: YamlValue) -> Result<JsonValue> {
728    serde_json::to_value(value).map_err(|err| anyhow!("yaml to json failed: {err}"))
729}
730
731fn inventory_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
732    (type_name.as_str().to_string(), key_string(key))
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use crate::planner::plan;
739    use crate::state::StateStore;
740    use crate::types::ObservedState;
741    use tempfile::tempdir;
742
743    fn parse_yaml(input: &str) -> YamlValue {
744        serde_yaml::from_str(input).unwrap()
745    }
746
747    #[test]
748    fn wildcard_selector_returns_all_nodes() {
749        let raw = parse_yaml(
750            r#"
751sites:
752  - slug: a
753    devices:
754      - name: d1
755      - name: d2
756  - slug: b
757    devices:
758      - name: d3
759"#,
760        );
761        let selectors = parse_selector_path("/sites/*/devices/*").unwrap();
762        let mut selected = Vec::new();
763        select_paths(&raw, &selectors, &mut Vec::new(), &mut selected);
764        assert_eq!(selected.len(), 3);
765    }
766
767    #[test]
768    fn templates_substitute_and_error_on_missing() {
769        let mut vars = BTreeMap::new();
770        vars.insert("name".to_string(), JsonValue::String("leaf01".to_string()));
771        let rendered = render_template("device=${name}", &vars, "devices", "key").unwrap();
772        assert_eq!(rendered, "device=leaf01");
773
774        let err = render_template("device=${missing}", &vars, "devices", "key").unwrap_err();
775        assert!(err.to_string().contains("missing var"));
776    }
777
778    #[test]
779    fn uid_v5_is_deterministic() {
780        let first = uid_v5("dcim.site", "site=fra1");
781        let second = uid_v5("dcim.site", "site=fra1");
782        assert_eq!(first, second);
783    }
784
785    #[test]
786    fn compile_raw_yaml_to_inventory() {
787        let raw = parse_yaml(
788            r#"
789sites:
790  - slug: fra1
791    name: FRA1
792    devices:
793      - name: leaf01
794        role: leaf
795        device_type: leaf-switch
796        model:
797          fabric: fra1-fabric
798          role_hint: leaf
799          tags:
800            - fabric
801        interfaces:
802          - name: eth0
803          - name: eth1
804prefixes:
805  - site: fra1
806    prefix: 10.0.0.0/24
807    ips:
808      - device: leaf01
809        interface: eth0
810        address: 10.0.0.10/24
811"#,
812        );
813        let retort = parse_yaml(include_str!("../../../examples/retort.yaml"));
814        let retort: Retort = serde_yaml::from_value(retort).unwrap();
815        let inventory = compile_retort(&raw, &retort).unwrap();
816        let json = serde_json::to_value(&inventory).unwrap();
817        let objects = json.get("objects").unwrap().as_array().unwrap();
818        assert_eq!(objects.len(), 6);
819        let types: Vec<&str> = objects
820            .iter()
821            .map(|obj| obj.get("type").unwrap().as_str().unwrap())
822            .collect();
823        assert!(types.contains(&"dcim.site"));
824        assert!(types.contains(&"dcim.device"));
825    }
826
827    #[test]
828    fn plan_is_deterministic_across_runs() {
829        let raw = parse_yaml(
830            r#"
831sites:
832  - slug: fra1
833    name: FRA1
834"#,
835        );
836        let retort = parse_yaml(
837            r#"
838version: 1
839schema:
840  types:
841    dcim.site:
842      key:
843        site:
844          type: slug
845      fields:
846        name:
847          type: string
848        slug:
849          type: slug
850rules:
851  - name: sites
852    select: /sites/*
853    emit:
854      type: dcim.site
855      key:
856        site: "${slug}"
857      vars:
858        slug: { from: .slug, required: true }
859        name: { from: .name, required: true }
860      attrs:
861        name: ${name}
862        slug: ${slug}
863"#,
864        );
865        let retort: Retort = serde_yaml::from_value(retort).unwrap();
866        let inventory = compile_retort(&raw, &retort).unwrap();
867        let state = StateStore::load(tempdir().unwrap().path().join("state.json")).unwrap();
868        let observed = ObservedState::default();
869        let first = plan(
870            &inventory.objects,
871            &observed,
872            &state,
873            &inventory.schema,
874            false,
875        );
876        let second = plan(
877            &inventory.objects,
878            &observed,
879            &state,
880            &inventory.schema,
881            false,
882        );
883        assert_eq!(first.ops, second.ops);
884    }
885
886    #[test]
887    fn parse_relative_path_tracks_parent_hops() {
888        let rel = parse_relative_path("^^.slug").unwrap();
889        assert_eq!(rel.up, 2);
890        assert_eq!(rel.selectors.len(), 1);
891    }
892
893    #[test]
894    fn render_uid_mapping_optional_skips_missing() {
895        let vars = BTreeMap::new();
896        let mapping: YamlValue = serde_yaml::from_str(
897            r#"
898uid?:
899  type: "dcim.site"
900  stable: "site=${slug}"
901"#,
902        )
903        .unwrap();
904        let rendered = render_yaml_value(&mapping, &vars, "rule", "attrs", false).unwrap();
905        assert!(rendered.is_none());
906    }
907
908    #[test]
909    fn render_uid_mapping_required_errors_on_missing() {
910        let vars = BTreeMap::new();
911        let mapping: YamlValue = serde_yaml::from_str(
912            r#"
913uid:
914  type: "dcim.site"
915  stable: "site=${slug}"
916"#,
917        )
918        .unwrap();
919        let err = render_yaml_value(&mapping, &vars, "rule", "attrs", false).unwrap_err();
920        assert!(err.to_string().contains("missing var"));
921    }
922
923    #[test]
924    fn template_errors_on_non_string_var() {
925        let mut vars = BTreeMap::new();
926        vars.insert("asn".to_string(), JsonValue::Number(65001.into()));
927        let err = render_template("asn=${asn}", &vars, "rule", "key").unwrap_err();
928        assert!(err.to_string().contains("must be a string"));
929    }
930
931    #[test]
932    fn resolve_uid_template_rejects_invalid_uuid() {
933        let vars = BTreeMap::new();
934        let err = resolve_uid_template("not-a-uuid", &vars, "rule").unwrap_err();
935        assert!(err.to_string().contains("uid template is not a valid uuid"));
936    }
937
938    #[test]
939    fn multi_emit_produces_multiple_objects() {
940        let raw = parse_yaml(
941            r#"
942fabrics:
943  - name: fabric1
944    site_slug: fra1
945    vrf_name: blue
946"#,
947        );
948        let retort = parse_yaml(
949            r#"
950version: 1
951schema:
952  types:
953    dcim.site:
954      key:
955        site:
956          type: slug
957      fields:
958        name:
959          type: string
960        slug:
961          type: slug
962    custom.vrf:
963      key:
964        vrf:
965          type: slug
966      fields:
967        name:
968          type: string
969rules:
970  - name: fabric
971    select: /fabrics/*
972    vars:
973      site_slug: { from: .site_slug, required: true }
974      vrf_name: { from: .vrf_name, required: true }
975    emit:
976      - type: dcim.site
977        key:
978          site: "${site_slug}"
979        attrs:
980          name: ${site_slug}
981          slug: ${site_slug}
982      - type: custom.vrf
983        key:
984          vrf: "${vrf_name}"
985        attrs:
986          name: ${vrf_name}
987"#,
988        );
989        let retort: Retort = serde_yaml::from_value(retort).unwrap();
990        let inventory = compile_retort(&raw, &retort).unwrap();
991        assert_eq!(inventory.objects.len(), 2);
992        // Objects are sorted by type then key.
993        assert_eq!(inventory.objects[0].type_name.as_str(), "custom.vrf");
994        assert_eq!(inventory.objects[1].type_name.as_str(), "dcim.site");
995    }
996
997    #[test]
998    fn multi_emit_with_named_uids() {
999        let raw = parse_yaml(
1000            r#"
1001fabrics:
1002  - site_slug: fra1
1003    vrf_name: blue
1004"#,
1005        );
1006        let retort = parse_yaml(
1007            r#"
1008version: 1
1009schema:
1010  types:
1011    dcim.site:
1012      key:
1013        site:
1014          type: slug
1015      fields:
1016        name:
1017          type: string
1018        slug:
1019          type: slug
1020    custom.vrf:
1021      key:
1022        vrf:
1023          type: slug
1024      fields:
1025        name:
1026          type: string
1027        site:
1028          type: ref
1029          target: dcim.site
1030rules:
1031  - name: fabric
1032    select: /fabrics/*
1033    vars:
1034      site_slug: { from: .site_slug, required: true }
1035      vrf_name: { from: .vrf_name, required: true }
1036    uids:
1037      site:
1038        v5:
1039          type: "dcim.site"
1040          stable: "site=${site_slug}"
1041    emit:
1042      - type: dcim.site
1043        key:
1044          site: "${site_slug}"
1045        uid: ${uids.site}
1046        attrs:
1047          name: ${site_slug}
1048          slug: ${site_slug}
1049      - type: custom.vrf
1050        key:
1051          vrf: "${vrf_name}"
1052        attrs:
1053          name: ${vrf_name}
1054          site: ${uids.site}
1055"#,
1056        );
1057        let retort: Retort = serde_yaml::from_value(retort).unwrap();
1058        let inventory = compile_retort(&raw, &retort).unwrap();
1059        assert_eq!(inventory.objects.len(), 2);
1060
1061        let mut site = None;
1062        let mut vrf = None;
1063        for object in &inventory.objects {
1064            match object.type_name.as_str() {
1065                "dcim.site" => site = Some(object),
1066                "custom.vrf" => vrf = Some(object),
1067                _ => {}
1068            }
1069        }
1070        let site = site.expect("expected dcim.site");
1071        let vrf = vrf.expect("expected custom.vrf");
1072
1073        // Site UID should match the named UID
1074        let expected_site_uid = uid_v5("dcim.site", "site=fra1");
1075        assert_eq!(site.uid, expected_site_uid);
1076
1077        // VRF should reference the site UID in its attrs
1078        let vrf_attrs = &vrf.attrs;
1079        let site_ref = vrf_attrs.get("site").unwrap().as_str().unwrap();
1080        assert_eq!(site_ref, expected_site_uid.to_string());
1081    }
1082
1083    #[test]
1084    fn multi_emit_is_deterministic() {
1085        let raw = parse_yaml(
1086            r#"
1087fabrics:
1088  - site_slug: fra1
1089    vrf_name: blue
1090  - site_slug: fra2
1091    vrf_name: red
1092"#,
1093        );
1094        let retort = parse_yaml(
1095            r#"
1096version: 1
1097schema:
1098  types:
1099    dcim.site:
1100      key:
1101        site:
1102          type: slug
1103      fields:
1104        slug:
1105          type: slug
1106    custom.vrf:
1107      key:
1108        vrf:
1109          type: slug
1110      fields:
1111        name:
1112          type: string
1113rules:
1114  - name: fabric
1115    select: /fabrics/*
1116    vars:
1117      site_slug: { from: .site_slug, required: true }
1118      vrf_name: { from: .vrf_name, required: true }
1119    emit:
1120      - type: dcim.site
1121        key:
1122          site: "${site_slug}"
1123        attrs:
1124          slug: ${site_slug}
1125      - type: custom.vrf
1126        key:
1127          vrf: "${vrf_name}"
1128        attrs:
1129          name: ${vrf_name}
1130"#,
1131        );
1132        let retort: Retort = serde_yaml::from_value(retort).unwrap();
1133        let first = compile_retort(&raw, &retort).unwrap();
1134        let second = compile_retort(&raw, &retort).unwrap();
1135
1136        assert_eq!(first.objects.len(), 4);
1137        assert_eq!(first.objects.len(), second.objects.len());
1138        for (a, b) in first.objects.iter().zip(second.objects.iter()) {
1139            assert_eq!(a.uid, b.uid);
1140            assert_eq!(a.type_name, b.type_name);
1141            assert_eq!(a.key, b.key);
1142        }
1143    }
1144
1145    #[test]
1146    fn emit_level_vars_override_rule_level() {
1147        let raw = parse_yaml(
1148            r#"
1149items:
1150  - name: item1
1151    override_name: overridden
1152"#,
1153        );
1154        let retort = parse_yaml(
1155            r#"
1156version: 1
1157schema:
1158  types:
1159    custom.first:
1160      key:
1161        first:
1162          type: slug
1163      fields:
1164        name:
1165          type: string
1166    custom.second:
1167      key:
1168        second:
1169          type: slug
1170      fields:
1171        name:
1172          type: string
1173rules:
1174  - name: items
1175    select: /items/*
1176    vars:
1177      name: { from: .name, required: true }
1178    emit:
1179      - type: custom.first
1180        key:
1181          first: "${name}"
1182        attrs:
1183          name: ${name}
1184      - type: custom.second
1185        key:
1186          second: "${name}"
1187        vars:
1188          name: { from: .override_name, required: true }
1189        attrs:
1190          name: ${name}
1191"#,
1192        );
1193        let retort: Retort = serde_yaml::from_value(retort).unwrap();
1194        let inventory = compile_retort(&raw, &retort).unwrap();
1195        assert_eq!(inventory.objects.len(), 2);
1196
1197        let first = &inventory.objects[0];
1198        let second = &inventory.objects[1];
1199
1200        // First uses rule-level var
1201        let first_attrs = &first.attrs;
1202        assert_eq!(first_attrs.get("name").unwrap().as_str().unwrap(), "item1");
1203
1204        // Second uses emit-level var (overrides rule-level)
1205        let second_attrs = &second.attrs;
1206        assert_eq!(
1207            second_attrs.get("name").unwrap().as_str().unwrap(),
1208            "overridden"
1209        );
1210    }
1211}