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