Skip to main content

alembic_engine/
transform.rs

1//! map: ir → ir transformation.
2//!
3//! map takes a canonical inventory and re-emits it under a different vocabulary —
4//! renaming types and fields, dropping or deriving values, rewiring references —
5//! using the shared render/emit half (`render_key`, `render_attrs`, templates +
6//! transforms). a `uid` is a derived projection of `(type, key)`, so references
7//! between objects are resolved internally and the emitted `uid`s (and the ref
8//! values that point at them) are re-derived at the boundary in a second pass.
9//!
10//! a rule selects source objects by a type-name pattern with optional field
11//! predicates and emits one or more target objects per match (fan-out), or — with
12//! `group_by` — buckets the matched objects and emits once per group (N->1
13//! aggregation). `lookups` follow a ref to read a field from the object it points
14//! at, so an emit can pull a value off a related object.
15
16use crate::predicate::{parse_predicates, Predicate, PredicateOp};
17use crate::render::{
18    render_attrs, render_key, render_template, RenderCtx, TransformRegistry, UidV5Spec,
19};
20use alembic_core::{
21    key_string, uid_v5, FieldType, Inventory, JsonMap, Key, Object, Schema, TypeName, Uid,
22};
23use anyhow::{anyhow, Context, Result};
24use serde::Deserialize;
25use serde_json::Value as JsonValue;
26use serde_yaml::Value as YamlValue;
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use uuid::Uuid;
30
31/// uid override for an emit: a deterministic `v5: { type, stable }` or an
32/// explicit uuid-string template.
33#[derive(Debug, Deserialize)]
34#[serde(untagged)]
35pub enum EmitUid {
36    V5 { v5: UidV5Spec },
37    Template(String),
38}
39
40/// a map specification: the target schema plus the transformation rules.
41#[derive(Debug, Deserialize)]
42pub struct MapSpec {
43    /// target schema; the output inventory is validated against it.
44    #[serde(default)]
45    pub schema: Schema,
46    #[serde(default)]
47    pub rules: Vec<MapRule>,
48    /// user-defined starlark transforms, consulted by `${var|name}` pipelines
49    /// before the built-ins (requires the `starlark` feature).
50    #[serde(default)]
51    pub transforms: Option<TransformsSpec>,
52    /// directory of the spec file, captured by `load_map_spec` and used to
53    /// resolve `transforms.file` and starlark `load()` paths. `None` for specs
54    /// parsed from strings: a relative `transforms.file` then resolves against
55    /// the process cwd and `load()` is an error.
56    #[serde(skip)]
57    pub base_dir: Option<PathBuf>,
58}
59
60/// the `transforms:` block of a map spec: starlark source from a file or
61/// inline. exactly one of the two must be set.
62#[derive(Debug, Deserialize)]
63pub struct TransformsSpec {
64    /// path to a starlark file; a relative path resolves against the spec file.
65    #[serde(default)]
66    pub file: Option<PathBuf>,
67    /// inline starlark source.
68    #[serde(default)]
69    pub inline: Option<String>,
70}
71
72/// a single ir→ir rule: select source objects, emit one or more target objects
73/// each. `uids` declares named uids (computed once per matched source) referenced
74/// as `${uids.name}` in emits — the mechanism for wiring cross-object refs in a
75/// multi-emit restructure.
76#[derive(Debug, Deserialize)]
77pub struct MapRule {
78    pub name: String,
79    /// source selector: a type-name pattern with optional field
80    /// predicates, e.g. `dcim.site`, `dcim.*`, or `dcim.device[attrs.role=leaf]`.
81    pub r#match: String,
82    /// when set, the matched objects are bucketed by this rendered template and
83    /// the rule emits once per group (N->1 aggregation) instead of once per
84    /// object. emits then draw on `${group.key}`, `${group.count}`, and
85    /// per-member values collected into lists as `${group.items.<path>}`.
86    #[serde(default)]
87    pub group_by: Option<String>,
88    /// reference lookups: each resolves a uid (`ref`) to an object in the input
89    /// and binds one of its fields (`get`) as `${lookup.name}`, so an emit can
90    /// read a value from the object a ref points at. resolved before `uids`.
91    #[serde(default)]
92    pub lookups: BTreeMap<String, Lookup>,
93    /// named uids computed once and available as `${uids.name}` in emits.
94    #[serde(default)]
95    pub uids: BTreeMap<String, EmitUid>,
96    /// a single emit (a mapping) or a list of emits.
97    pub emit: EmitSpec,
98}
99
100/// a reference lookup: render `ref` to a uid, find that object in the input, and
101/// read the dotted field path `get` from it (e.g. `attrs.name`).
102#[derive(Debug, Deserialize)]
103pub struct Lookup {
104    pub r#ref: String,
105    pub get: String,
106}
107
108/// either a single emit (a mapping) or a list of emits.
109#[derive(Debug, Deserialize)]
110#[serde(untagged)]
111pub enum EmitSpec {
112    Single(MapEmit),
113    Multi(Vec<MapEmit>),
114}
115
116/// one emitted target object.
117#[derive(Debug, Deserialize)]
118pub struct MapEmit {
119    /// target type name (templates allowed).
120    #[serde(rename = "type", alias = "kind")]
121    pub type_name: String,
122    pub key: BTreeMap<String, YamlValue>,
123    /// optional uid override; defaults to `uid_v5(target_type, target_key)`.
124    #[serde(default)]
125    pub uid: Option<EmitUid>,
126    #[serde(default)]
127    pub attrs: BTreeMap<String, YamlValue>,
128}
129
130/// a compiled `match` selector: a type-name pattern plus field predicates over
131/// the source object (`dcim.device[attrs.role=leaf]`). predicates are evaluated
132/// against the same dotted var namespace as templates (`attrs.*`, `key.*`,
133/// `type`, `uid`).
134struct Matcher {
135    glob: TypeGlob,
136    predicates: Vec<Predicate>,
137}
138
139/// a type-name pattern: `*` (any), a trailing-`*` prefix (`dcim.*`), or exact.
140enum TypeGlob {
141    Any,
142    Prefix(String),
143    Exact(String),
144}
145
146impl Matcher {
147    fn parse(selector: &str) -> Result<Self> {
148        let selector = selector.trim();
149        let (base, predicates) = match selector.find('[') {
150            Some(idx) => (selector[..idx].trim(), parse_predicates(&selector[idx..])?),
151            None => (selector, Vec::new()),
152        };
153        if base.is_empty() {
154            return Err(anyhow!("match selector requires a type pattern"));
155        }
156        let glob = if base == "*" {
157            TypeGlob::Any
158        } else if let Some(prefix) = base.strip_suffix('*') {
159            TypeGlob::Prefix(prefix.to_string())
160        } else {
161            TypeGlob::Exact(base.to_string())
162        };
163        Ok(Self { glob, predicates })
164    }
165
166    fn type_matches(&self, type_name: &str) -> bool {
167        match &self.glob {
168            TypeGlob::Any => true,
169            TypeGlob::Prefix(prefix) => type_name.starts_with(prefix),
170            TypeGlob::Exact(exact) => type_name == exact,
171        }
172    }
173
174    fn predicates_match(&self, vars: &BTreeMap<String, JsonValue>) -> bool {
175        self.predicates
176            .iter()
177            .all(|pred| predicate_matches(pred, vars))
178    }
179}
180
181/// evaluate a predicate against the object's flattened var namespace, with
182/// scalar-comparison and existence semantics.
183fn predicate_matches(pred: &Predicate, vars: &BTreeMap<String, JsonValue>) -> bool {
184    let field = vars.get(&pred.field);
185    match pred.op {
186        PredicateOp::Exists => matches!(field, Some(value) if !value.is_null()),
187        PredicateOp::NotExists => match field {
188            Some(value) => value.is_null(),
189            None => true,
190        },
191        PredicateOp::Eq => {
192            matches!(field.and_then(json_scalar), Some(rendered) if rendered == pred.value)
193        }
194        PredicateOp::Ne => {
195            matches!(field.and_then(json_scalar), Some(rendered) if rendered != pred.value)
196        }
197    }
198}
199
200/// render a scalar json value to the text a predicate compares against; mirrors
201/// `predicate` scalar rules for the json value model.
202fn json_scalar(value: &JsonValue) -> Option<String> {
203    crate::render::scalar_string(value)
204}
205
206/// load a map spec from a yaml file. the spec remembers the file's directory so
207/// `transforms.file` and starlark `load()` paths resolve relative to it.
208pub fn load_map_spec(path: impl AsRef<Path>) -> Result<MapSpec> {
209    let path = path.as_ref();
210    let raw = std::fs::read_to_string(path)
211        .with_context(|| format!("read map spec: {}", path.display()))?;
212    let mut spec: MapSpec = serde_yaml::from_str(&raw)
213        .with_context(|| format!("parse map spec: {}", path.display()))?;
214    spec.base_dir = path.parent().map(Path::to_path_buf);
215    Ok(spec)
216}
217
218/// build the transform registry for a spec: empty (built-ins only) without a
219/// `transforms:` block, otherwise the compiled starlark module. starlark is
220/// compiled once here, not per template.
221fn transform_registry(spec: &MapSpec) -> Result<TransformRegistry> {
222    let Some(transforms) = &spec.transforms else {
223        return Ok(TransformRegistry::EMPTY);
224    };
225    #[cfg(not(feature = "starlark"))]
226    {
227        let _ = transforms;
228        Err(anyhow!(
229            "map spec has a transforms block but alembic-engine was built without the starlark feature"
230        ))
231    }
232    #[cfg(feature = "starlark")]
233    {
234        let (source, filename) = match (&transforms.file, &transforms.inline) {
235            (Some(_), Some(_)) | (None, None) => {
236                return Err(anyhow!(
237                    "map spec transforms: requires exactly one of file or inline"
238                ));
239            }
240            (Some(file), None) => {
241                let path = match &spec.base_dir {
242                    Some(base) if file.is_relative() => base.join(file),
243                    _ => file.clone(),
244                };
245                let source = std::fs::read_to_string(&path)
246                    .with_context(|| format!("read transforms file: {}", path.display()))?;
247                (source, path.display().to_string())
248            }
249            (None, Some(inline)) => (inline.clone(), "transforms".to_string()),
250        };
251        let user = crate::starlark_transforms::StarlarkTransforms::compile(
252            &source,
253            &filename,
254            spec.base_dir.as_deref(),
255        )?;
256        Ok(TransformRegistry::with_user(user))
257    }
258}
259
260/// evaluate a single named transform from a map spec against a json value,
261/// consulting the spec's user transforms first, then the built-ins. backs
262/// `alembic map transform`, the iteration loop for writing transforms: one
263/// value in, the typed result out, no inventory or backend involved.
264pub fn eval_map_transform(
265    spec: &MapSpec,
266    name: &str,
267    value: &JsonValue,
268    args: &[JsonValue],
269) -> Result<JsonValue> {
270    let registry = transform_registry(spec)?;
271    crate::render::apply_single_transform(&registry, name, value, args)
272}
273
274/// per-run immutables shared by every emit: the compiled transform registry and
275/// the source-object index used to resolve reference lookups.
276struct MapRun<'a> {
277    transforms: TransformRegistry,
278    index: BTreeMap<Uid, &'a Object>,
279}
280
281/// transform an ir inventory into another ir inventory under the target schema.
282pub fn compile_map(input: &Inventory, spec: &MapSpec) -> Result<Inventory> {
283    let run = MapRun {
284        transforms: transform_registry(spec)?,
285        // source uid -> object, for resolving reference lookups.
286        index: input.objects.iter().map(|o| (o.uid, o)).collect(),
287    };
288    let mut objects = Vec::new();
289    // source uid -> emitted uid, used to re-derive ref values in pass 2.
290    let mut remap: BTreeMap<Uid, Uid> = BTreeMap::new();
291
292    for rule in &spec.rules {
293        let matcher = Matcher::parse(&rule.r#match)
294            .with_context(|| format!("rule {}: invalid match selector", rule.name))?;
295        let emits = match &rule.emit {
296            EmitSpec::Single(emit) => std::slice::from_ref(emit),
297            EmitSpec::Multi(emits) => emits.as_slice(),
298        };
299        match &rule.group_by {
300            None => {
301                // a rule emitting exactly one object per source is a 1:1 rename,
302                // so we record source->target for automatic ref-rewiring. a
303                // multi-emit rule is a restructure where auto-rewiring would be
304                // ambiguous, so its cross-object refs are wired explicitly via
305                // named `uids` instead.
306                let remap_each = emits.len() == 1;
307                for src in input.objects.iter() {
308                    if !matcher.type_matches(src.type_name.as_str()) {
309                        continue;
310                    }
311                    let vars = object_vars(src);
312                    if !matcher.predicates_match(&vars) {
313                        continue;
314                    }
315                    let remap_source = remap_each.then_some(src.uid);
316                    emit_objects(
317                        rule,
318                        emits,
319                        vars,
320                        &run,
321                        remap_source,
322                        &mut objects,
323                        &mut remap,
324                    )?;
325                }
326            }
327            // aggregation: bucket matched sources by the rendered key, emit once
328            // per group. N->1, so no auto ref-rewiring (cross-object refs use
329            // named `uids`). a BTreeMap keys groups deterministically; members
330            // stay in input order.
331            Some(group_expr) => {
332                let mut groups: BTreeMap<String, Vec<&Object>> = BTreeMap::new();
333                for src in input.objects.iter() {
334                    if !matcher.type_matches(src.type_name.as_str()) {
335                        continue;
336                    }
337                    let vars = object_vars(src);
338                    if !matcher.predicates_match(&vars) {
339                        continue;
340                    }
341                    let group_key = render_template(
342                        group_expr,
343                        &RenderCtx {
344                            vars: &vars,
345                            transforms: &run.transforms,
346                            rule: &rule.name,
347                        },
348                        "group_by",
349                    )?;
350                    groups.entry(group_key).or_default().push(src);
351                }
352                for (group_key, members) in &groups {
353                    let vars = group_vars(group_key, members);
354                    emit_objects(rule, emits, vars, &run, None, &mut objects, &mut remap)?;
355                }
356            }
357        }
358    }
359
360    rewrite_refs(&mut objects, &spec.schema, &remap);
361
362    objects.sort_by(|a, b| {
363        (a.type_name.as_str(), key_string(&a.key)).cmp(&(b.type_name.as_str(), key_string(&b.key)))
364    });
365
366    let inventory = Inventory {
367        schema: spec.schema.clone(),
368        objects,
369    };
370    crate::report_to_result(crate::validate(&inventory))?;
371    Ok(inventory)
372}
373
374/// compute named uids and run a rule's emits against `vars`, pushing the
375/// resulting objects. `remap_source`, when set, records source->target uid for
376/// the automatic ref-rewiring pass (1:1 rules only).
377fn emit_objects(
378    rule: &MapRule,
379    emits: &[MapEmit],
380    mut vars: BTreeMap<String, JsonValue>,
381    run: &MapRun,
382    remap_source: Option<Uid>,
383    objects: &mut Vec<Object>,
384    remap: &mut BTreeMap<Uid, Uid>,
385) -> Result<()> {
386    // resolve reference lookups first, so named uids and emits can use them.
387    // bound under `lookup.<name>`, mirroring `uids.<name>`, so a lookup can never
388    // shadow the object's own vars (`uid`, `key.*`, `attrs.*`, ...).
389    for (name, lookup) in &rule.lookups {
390        let ctx = RenderCtx {
391            vars: &vars,
392            transforms: &run.transforms,
393            rule: &rule.name,
394        };
395        let value = resolve_lookup(name, lookup, &ctx, &run.index)?;
396        vars.insert(format!("lookup.{name}"), value);
397    }
398    // compute named uids once, exposed as `uids.name` to every emit.
399    for (name, uid_spec) in &rule.uids {
400        let context = format!("uids.{name}");
401        let ctx = RenderCtx {
402            vars: &vars,
403            transforms: &run.transforms,
404            rule: &rule.name,
405        };
406        let uid = resolve_uid_spec(uid_spec, &ctx, &context)?;
407        vars.insert(context, JsonValue::String(uid.to_string()));
408    }
409    let ctx = RenderCtx {
410        vars: &vars,
411        transforms: &run.transforms,
412        rule: &rule.name,
413    };
414    for emit in emits {
415        let key = render_key(&emit.key, &ctx)?;
416        let type_name = TypeName::new(render_template(&emit.type_name, &ctx, "type")?);
417        let uid = resolve_emit_uid(&emit.uid, &ctx, type_name.as_str(), &key)?;
418        let attrs = render_attrs(&emit.attrs, &ctx, "attrs")?;
419        let attrs = JsonMap::from(attrs.into_iter().collect::<BTreeMap<_, _>>());
420
421        if let Some(source) = remap_source {
422            if let Some(prev) = remap.insert(source, uid) {
423                if prev != uid {
424                    return Err(anyhow!(
425                        "source object {source} is matched by multiple rules emitting different uids"
426                    ));
427                }
428            }
429        }
430        objects.push(Object::new(uid, type_name, key, attrs)?);
431    }
432    Ok(())
433}
434
435/// resolve a reference lookup: render `ref` to a uid, find that object in the
436/// input index, and read its `get` field path. strict: a ref that is not a uuid,
437/// a uid not in the input, or a missing field is an error.
438fn resolve_lookup(
439    name: &str,
440    lookup: &Lookup,
441    ctx: &RenderCtx,
442    index: &BTreeMap<Uid, &Object>,
443) -> Result<JsonValue> {
444    let rule = ctx.rule;
445    let context = format!("lookups.{name}");
446    let rendered = render_template(&lookup.r#ref, ctx, &context)?;
447    let uid = Uuid::parse_str(&rendered).with_context(|| {
448        format!("rule {rule}: lookup {name} ref is not a valid uuid: {rendered}")
449    })?;
450    let referent = index
451        .get(&uid)
452        .ok_or_else(|| anyhow!("rule {rule}: lookup {name} ref {uid} is not in the input"))?;
453    object_vars(referent)
454        .get(&lookup.get)
455        .cloned()
456        .ok_or_else(|| {
457            anyhow!(
458                "rule {rule}: lookup {name} field {} is absent on {uid}",
459                lookup.get
460            )
461        })
462}
463
464/// build the template vars for an aggregation group: `group.key`, `group.count`,
465/// and every member field collected into a list under `group.items.<path>`
466/// (present, non-missing values in member order).
467fn group_vars(group_key: &str, members: &[&Object]) -> BTreeMap<String, JsonValue> {
468    let mut vars = BTreeMap::new();
469    vars.insert(
470        "group.key".to_string(),
471        JsonValue::String(group_key.to_string()),
472    );
473    vars.insert(
474        "group.count".to_string(),
475        JsonValue::Number(members.len().into()),
476    );
477
478    let per_member: Vec<BTreeMap<String, JsonValue>> =
479        members.iter().map(|member| object_vars(member)).collect();
480    let mut paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
481    for member in &per_member {
482        paths.extend(member.keys().cloned());
483    }
484    for path in paths {
485        let values: Vec<JsonValue> = per_member
486            .iter()
487            .filter_map(|member| member.get(&path).filter(|v| !v.is_null()).cloned())
488            .collect();
489        vars.insert(format!("group.items.{path}"), JsonValue::Array(values));
490    }
491    vars
492}
493
494/// build the template vars for a source object: `uid`, `type`, and every key /
495/// attr field flattened to a dotted path (so `${attrs.model.fabric}` works).
496fn object_vars(obj: &Object) -> BTreeMap<String, JsonValue> {
497    let mut vars = BTreeMap::new();
498    vars.insert("uid".to_string(), JsonValue::String(obj.uid.to_string()));
499    vars.insert(
500        "type".to_string(),
501        JsonValue::String(obj.type_name.as_str().to_string()),
502    );
503    for (field, value) in obj.key.iter() {
504        flatten(&format!("key.{field}"), value, &mut vars);
505    }
506    for (field, value) in obj.attrs.iter() {
507        flatten(&format!("attrs.{field}"), value, &mut vars);
508    }
509    vars
510}
511
512/// insert `value` at `prefix`, recursing into objects so both the whole value
513/// (`attrs.model`) and its leaves (`attrs.model.fabric`) are addressable.
514fn flatten(prefix: &str, value: &JsonValue, out: &mut BTreeMap<String, JsonValue>) {
515    out.insert(prefix.to_string(), value.clone());
516    if let JsonValue::Object(map) = value {
517        for (field, child) in map {
518            flatten(&format!("{prefix}.{field}"), child, out);
519        }
520    }
521}
522
523/// resolve an emit's uid: the default derives `uid_v5(target_type, target_key)`;
524/// an explicit override defers to `resolve_uid_spec`.
525fn resolve_emit_uid(
526    uid: &Option<EmitUid>,
527    ctx: &RenderCtx,
528    type_name: &str,
529    key: &Key,
530) -> Result<Uid> {
531    match uid {
532        None => Ok(uid_v5(type_name, &key_string(key))),
533        Some(spec) => resolve_uid_spec(spec, ctx, "uid"),
534    }
535}
536
537/// resolve an explicit uid spec — a `v5: {type, stable}` pair or a uuid-string
538/// template — against the current vars. shared by emit uids and named `uids`.
539fn resolve_uid_spec(spec: &EmitUid, ctx: &RenderCtx, context: &str) -> Result<Uid> {
540    let rule = ctx.rule;
541    match spec {
542        EmitUid::Template(template) => {
543            let rendered = render_template(template, ctx, context)?;
544            Uuid::parse_str(&rendered).with_context(|| {
545                format!("rule {rule}: uid template is not a valid uuid: {rendered}")
546            })
547        }
548        EmitUid::V5 { v5 } => {
549            let kind = render_template(&v5.type_name, ctx, context)?;
550            let stable = render_template(&v5.stable, ctx, context)?;
551            crate::render::derive_v5_uid(&kind, &stable, rule)
552        }
553    }
554}
555
556/// rewrite the references in each object's attrs through the source→target uid
557/// remap, using the target schema to find which fields hold them.
558fn rewrite_refs(objects: &mut [Object], schema: &Schema, remap: &BTreeMap<Uid, Uid>) {
559    for obj in objects.iter_mut() {
560        let Some(type_schema) = schema.types.get(obj.type_name.as_str()) else {
561            continue;
562        };
563        for (field, field_schema) in &type_schema.fields {
564            if let Some(value) = obj.attrs.get_mut(field) {
565                rewrite_refs_in_value(&field_schema.r#type, value, remap);
566            }
567        }
568    }
569}
570
571/// rewrite the ref uids a value carries, recursing through list and map
572/// containers so nested refs are remapped too.
573fn rewrite_refs_in_value(
574    field_type: &FieldType,
575    value: &mut JsonValue,
576    remap: &BTreeMap<Uid, Uid>,
577) {
578    match field_type {
579        FieldType::Ref { .. } => rewrite_ref_value(value, remap),
580        FieldType::ListRef { .. } => {
581            if let JsonValue::Array(items) = value {
582                for item in items {
583                    rewrite_ref_value(item, remap);
584                }
585            }
586        }
587        FieldType::List { item } => {
588            if let JsonValue::Array(items) = value {
589                for item_value in items {
590                    rewrite_refs_in_value(item, item_value, remap);
591                }
592            }
593        }
594        FieldType::Map { value: inner } => {
595            if let JsonValue::Object(map) = value {
596                for entry in map.values_mut() {
597                    rewrite_refs_in_value(inner, entry, remap);
598                }
599            }
600        }
601        _ => {}
602    }
603}
604
605fn rewrite_ref_value(value: &mut JsonValue, remap: &BTreeMap<Uid, Uid>) {
606    let JsonValue::String(raw) = value else {
607        return;
608    };
609    let Ok(old) = Uuid::parse_str(raw) else {
610        return;
611    };
612    if let Some(new) = remap.get(&old) {
613        *value = JsonValue::String(new.to_string());
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use serde_json::json;
621
622    fn input_inventory(objects: JsonValue) -> Inventory {
623        serde_json::from_value(json!({ "schema": { "types": {} }, "objects": objects })).unwrap()
624    }
625
626    fn spec(yaml: &str) -> MapSpec {
627        serde_yaml::from_str(yaml).unwrap()
628    }
629
630    #[test]
631    fn renames_type_and_field_carrying_key() {
632        let input = input_inventory(json!([
633            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.site",
634              "key": { "site": "fra1" }, "attrs": { "name": "FRA1" } }
635        ]));
636        let out = compile_map(
637            &input,
638            &spec(
639                r#"
640schema:
641  types:
642    location.site:
643      key:
644        slug: { type: slug }
645      fields:
646        label: { type: string }
647rules:
648  - name: sites
649    match: "dcim.site"
650    emit:
651      type: location.site
652      key:
653        slug: "${key.site}"
654      attrs:
655        label: "${attrs.name}"
656"#,
657            ),
658        )
659        .unwrap();
660
661        assert_eq!(out.objects.len(), 1);
662        let obj = &out.objects[0];
663        assert_eq!(obj.type_name.as_str(), "location.site");
664        assert_eq!(obj.key.get("slug").unwrap(), &json!("fra1"));
665        assert_eq!(obj.attrs.get("label").unwrap(), &json!("FRA1"));
666        // default uid is derived from the *target* identity, deterministically.
667        assert_eq!(obj.uid, uid_v5("location.site", &key_string(&obj.key)));
668    }
669
670    #[test]
671    fn drops_unmapped_fields_and_derives_via_transform() {
672        let input = input_inventory(json!([
673            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.site",
674              "key": { "site": "fra1" }, "attrs": { "name": "frankfurt", "secret": "drop me" } }
675        ]));
676        let out = compile_map(
677            &input,
678            &spec(
679                r#"
680schema:
681  types:
682    location.site:
683      key:
684        slug: { type: slug }
685      fields:
686        name: { type: string }
687rules:
688  - name: sites
689    match: "dcim.site"
690    emit:
691      type: location.site
692      key:
693        slug: "${key.site}"
694      attrs:
695        name: "${attrs.name|upper}"
696"#,
697            ),
698        )
699        .unwrap();
700
701        let attrs = &out.objects[0].attrs;
702        assert_eq!(attrs.get("name").unwrap(), &json!("FRANKFURT"));
703        assert!(attrs.get("secret").is_none());
704    }
705
706    #[test]
707    fn rewires_refs_across_a_rename() {
708        let site_src = Uuid::from_u128(1).to_string();
709        let input = input_inventory(json!([
710            { "uid": site_src, "type": "dcim.site",
711              "key": { "site": "fra1" }, "attrs": { "name": "FRA1" } },
712            { "uid": Uuid::from_u128(2).to_string(), "type": "dcim.device",
713              "key": { "device": "leaf01" }, "attrs": { "name": "leaf01", "site": site_src } }
714        ]));
715        let out = compile_map(
716            &input,
717            &spec(
718                r#"
719schema:
720  types:
721    location.site:
722      key:
723        slug: { type: slug }
724      fields:
725        name: { type: string }
726    dcim.device:
727      key:
728        device: { type: slug }
729      fields:
730        name: { type: string }
731        site: { type: ref, target: location.site }
732rules:
733  - name: sites
734    match: "dcim.site"
735    emit:
736      type: location.site
737      key:
738        slug: "${key.site}"
739      attrs:
740        name: "${attrs.name}"
741  - name: devices
742    match: "dcim.device"
743    emit:
744      type: dcim.device
745      key:
746        device: "${key.device}"
747      attrs:
748        name: "${attrs.name}"
749        site: "${attrs.site}"
750"#,
751            ),
752        )
753        .unwrap();
754
755        let site = out
756            .objects
757            .iter()
758            .find(|o| o.type_name.as_str() == "location.site")
759            .unwrap();
760        let device = out
761            .objects
762            .iter()
763            .find(|o| o.type_name.as_str() == "dcim.device")
764            .unwrap();
765        // the device's ref now points at the *new* site uid, not the source one.
766        assert_eq!(
767            device.attrs.get("site").unwrap(),
768            &json!(site.uid.to_string())
769        );
770        assert_ne!(device.attrs.get("site").unwrap(), &json!(site_src));
771    }
772
773    #[test]
774    fn rewires_refs_nested_in_a_list_field() {
775        let a_src = Uuid::from_u128(1).to_string();
776        let b_src = Uuid::from_u128(2).to_string();
777        let input = input_inventory(json!([
778            { "uid": a_src, "type": "dcim.device",
779              "key": { "device": "leaf01" },
780              "attrs": { "name": "leaf01", "peers": [b_src] } },
781            { "uid": b_src, "type": "dcim.device",
782              "key": { "device": "leaf02" },
783              "attrs": { "name": "leaf02", "peers": [a_src] } }
784        ]));
785        let out = compile_map(
786            &input,
787            &spec(
788                r#"
789schema:
790  types:
791    net.node:
792      key:
793        name: { type: slug }
794      fields:
795        name: { type: string }
796        peers: { type: list, item: { type: ref, target: net.node } }
797rules:
798  - name: nodes
799    match: "dcim.device"
800    emit:
801      type: net.node
802      key:
803        name: "${key.device}"
804      attrs:
805        name: "${attrs.name}"
806        peers: "${attrs.peers}"
807"#,
808            ),
809        )
810        .unwrap();
811
812        let node = |name: &str| {
813            out.objects
814                .iter()
815                .find(|o| o.key.get("name").unwrap() == &json!(name))
816                .unwrap()
817        };
818        let a = node("leaf01");
819        let b = node("leaf02");
820        // the peer refs nested in the `list` field now point at the new uids.
821        assert_eq!(a.attrs.get("peers").unwrap(), &json!([b.uid.to_string()]));
822        assert_eq!(b.attrs.get("peers").unwrap(), &json!([a.uid.to_string()]));
823    }
824
825    #[test]
826    fn rewires_refs_in_a_list_ref_field() {
827        let a_src = Uuid::from_u128(1).to_string();
828        let b_src = Uuid::from_u128(2).to_string();
829        let input = input_inventory(json!([
830            { "uid": a_src, "type": "dcim.device",
831              "key": { "device": "leaf01" },
832              "attrs": { "name": "leaf01", "peers": [b_src] } },
833            { "uid": b_src, "type": "dcim.device",
834              "key": { "device": "leaf02" },
835              "attrs": { "name": "leaf02", "peers": [a_src] } }
836        ]));
837        let out = compile_map(
838            &input,
839            &spec(
840                r#"
841schema:
842  types:
843    net.node:
844      key:
845        name: { type: slug }
846      fields:
847        name: { type: string }
848        peers: { type: list_ref, target: net.node }
849rules:
850  - name: nodes
851    match: "dcim.device"
852    emit:
853      type: net.node
854      key:
855        name: "${key.device}"
856      attrs:
857        name: "${attrs.name}"
858        peers: "${attrs.peers}"
859"#,
860            ),
861        )
862        .unwrap();
863
864        let node = |name: &str| {
865            out.objects
866                .iter()
867                .find(|o| o.key.get("name").unwrap() == &json!(name))
868                .unwrap()
869        };
870        let a = node("leaf01");
871        let b = node("leaf02");
872        // the peer refs in the `list_ref` field now point at the new uids.
873        assert_eq!(a.attrs.get("peers").unwrap(), &json!([b.uid.to_string()]));
874        assert_eq!(b.attrs.get("peers").unwrap(), &json!([a.uid.to_string()]));
875    }
876
877    #[test]
878    fn rewires_refs_nested_in_a_map_field() {
879        let a_src = Uuid::from_u128(1).to_string();
880        let b_src = Uuid::from_u128(2).to_string();
881        let input = input_inventory(json!([
882            { "uid": a_src, "type": "dcim.device",
883              "key": { "device": "leaf01" },
884              "attrs": { "name": "leaf01", "peers": { "primary": b_src } } },
885            { "uid": b_src, "type": "dcim.device",
886              "key": { "device": "leaf02" },
887              "attrs": { "name": "leaf02", "peers": { "primary": a_src } } }
888        ]));
889        let out = compile_map(
890            &input,
891            &spec(
892                r#"
893schema:
894  types:
895    net.node:
896      key:
897        name: { type: slug }
898      fields:
899        name: { type: string }
900        peers: { type: map, value: { type: ref, target: net.node } }
901rules:
902  - name: nodes
903    match: "dcim.device"
904    emit:
905      type: net.node
906      key:
907        name: "${key.device}"
908      attrs:
909        name: "${attrs.name}"
910        peers: "${attrs.peers}"
911"#,
912            ),
913        )
914        .unwrap();
915
916        let node = |name: &str| {
917            out.objects
918                .iter()
919                .find(|o| o.key.get("name").unwrap() == &json!(name))
920                .unwrap()
921        };
922        let a = node("leaf01");
923        let b = node("leaf02");
924        // the peer refs nested in the `map` field now point at the new uids.
925        assert_eq!(
926            a.attrs.get("peers").unwrap(),
927            &json!({ "primary": b.uid.to_string() })
928        );
929        assert_eq!(
930            b.attrs.get("peers").unwrap(),
931            &json!({ "primary": a.uid.to_string() })
932        );
933    }
934
935    #[test]
936    fn is_deterministic_across_runs() {
937        let input = input_inventory(json!([
938            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.site",
939              "key": { "site": "fra1" }, "attrs": { "name": "FRA1" } },
940            { "uid": Uuid::from_u128(2).to_string(), "type": "dcim.site",
941              "key": { "site": "ams1" }, "attrs": { "name": "AMS1" } }
942        ]));
943        let yaml = r#"
944schema:
945  types:
946    location.site:
947      key:
948        slug: { type: slug }
949      fields:
950        name: { type: string }
951rules:
952  - name: sites
953    match: "dcim.site"
954    emit:
955      type: location.site
956      key:
957        slug: "${key.site}"
958      attrs:
959        name: "${attrs.name}"
960"#;
961        let first = compile_map(&input, &spec(yaml)).unwrap();
962        let second = compile_map(&input, &spec(yaml)).unwrap();
963        assert_eq!(first.objects, second.objects);
964    }
965
966    #[test]
967    fn type_glob_matches_a_prefix() {
968        let input = input_inventory(json!([
969            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.site",
970              "key": { "k": "a" }, "attrs": {} },
971            { "uid": Uuid::from_u128(2).to_string(), "type": "dcim.device",
972              "key": { "k": "b" }, "attrs": {} },
973            { "uid": Uuid::from_u128(3).to_string(), "type": "ipam.prefix",
974              "key": { "k": "c" }, "attrs": {} }
975        ]));
976        let out = compile_map(
977            &input,
978            &spec(
979                r#"
980schema:
981  types:
982    thing:
983      key:
984        k: { type: string }
985rules:
986  - name: dcim-only
987    match: "dcim.*"
988    emit:
989      type: thing
990      key:
991        k: "${key.k}"
992"#,
993            ),
994        )
995        .unwrap();
996        // both dcim.* objects map to `thing`; the ipam.prefix is left out.
997        assert_eq!(out.objects.len(), 2);
998        assert!(out.objects.iter().all(|o| o.type_name.as_str() == "thing"));
999        let keys: Vec<&str> = out
1000            .objects
1001            .iter()
1002            .map(|o| o.key.get("k").unwrap().as_str().unwrap())
1003            .collect();
1004        assert_eq!(keys, vec!["a", "b"]);
1005    }
1006
1007    #[test]
1008    fn predicate_filters_matched_objects() {
1009        let input = input_inventory(json!([
1010            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1011              "key": { "device": "leaf01" }, "attrs": { "role": "leaf" } },
1012            { "uid": Uuid::from_u128(2).to_string(), "type": "dcim.device",
1013              "key": { "device": "spine01" }, "attrs": { "role": "spine" } },
1014            { "uid": Uuid::from_u128(3).to_string(), "type": "dcim.device",
1015              "key": { "device": "leaf02" }, "attrs": { "role": "leaf" } }
1016        ]));
1017        let out = compile_map(
1018            &input,
1019            &spec(
1020                r#"
1021schema:
1022  types:
1023    fabric.leaf:
1024      key:
1025        name: { type: slug }
1026rules:
1027  - name: leaves
1028    match: "dcim.device[attrs.role=leaf]"
1029    emit:
1030      type: fabric.leaf
1031      key:
1032        name: "${key.device}"
1033"#,
1034            ),
1035        )
1036        .unwrap();
1037        // only the two leaves survive the predicate; the spine is filtered out.
1038        let names: Vec<&str> = out
1039            .objects
1040            .iter()
1041            .map(|o| o.key.get("name").unwrap().as_str().unwrap())
1042            .collect();
1043        assert_eq!(names, vec!["leaf01", "leaf02"]);
1044    }
1045
1046    #[test]
1047    fn multi_predicate_selector_ands_predicates() {
1048        let input = input_inventory(json!([
1049            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1050              "key": { "device": "leaf-cisco" }, "attrs": { "role": "leaf", "vendor": "cisco" } },
1051            { "uid": Uuid::from_u128(2).to_string(), "type": "dcim.device",
1052              "key": { "device": "leaf-arista" }, "attrs": { "role": "leaf", "vendor": "arista" } },
1053            { "uid": Uuid::from_u128(3).to_string(), "type": "dcim.device",
1054              "key": { "device": "spine-cisco" }, "attrs": { "role": "spine", "vendor": "cisco" } }
1055        ]));
1056        let out = compile_map(
1057            &input,
1058            &spec(
1059                r#"
1060schema:
1061  types:
1062    fabric.leaf:
1063      key:
1064        name: { type: slug }
1065rules:
1066  - name: cisco-leaves
1067    match: "dcim.device[attrs.role=leaf][attrs.vendor=cisco]"
1068    emit:
1069      type: fabric.leaf
1070      key:
1071        name: "${key.device}"
1072"#,
1073            ),
1074        )
1075        .unwrap();
1076        let names: Vec<&str> = out
1077            .objects
1078            .iter()
1079            .map(|o| o.key.get("name").unwrap().as_str().unwrap())
1080            .collect();
1081        assert_eq!(names, vec!["leaf-cisco"]);
1082    }
1083
1084    #[test]
1085    fn existence_predicates_filter_on_presence() {
1086        let input = input_inventory(json!([
1087            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1088              "key": { "device": "leaf01" }, "attrs": { "primary_ip": "10.0.0.1" } },
1089            { "uid": Uuid::from_u128(2).to_string(), "type": "dcim.device",
1090              "key": { "device": "leaf02" }, "attrs": {} },
1091            { "uid": Uuid::from_u128(3).to_string(), "type": "dcim.device",
1092              "key": { "device": "leaf03" }, "attrs": { "primary_ip": null } }
1093        ]));
1094        let template = r#"
1095schema:
1096  types:
1097    fabric.leaf:
1098      key:
1099        name: { type: slug }
1100rules:
1101  - name: leaves
1102    match: "SELECTOR"
1103    emit:
1104      type: fabric.leaf
1105      key:
1106        name: "${key.device}"
1107"#;
1108        let names = |selector: &str| -> Vec<String> {
1109            compile_map(&input, &spec(&template.replace("SELECTOR", selector)))
1110                .unwrap()
1111                .objects
1112                .iter()
1113                .map(|o| o.key.get("name").unwrap().as_str().unwrap().to_string())
1114                .collect()
1115        };
1116        // `[field]` keeps present, non-null values; `[!field]` is its complement,
1117        // with both absent and null counting as missing.
1118        assert_eq!(names("dcim.device[attrs.primary_ip]"), vec!["leaf01"]);
1119        assert_eq!(
1120            names("dcim.device[!attrs.primary_ip]"),
1121            vec!["leaf02", "leaf03"]
1122        );
1123    }
1124
1125    #[test]
1126    fn ne_predicate_excludes_matching_objects() {
1127        let input = input_inventory(json!([
1128            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1129              "key": { "device": "leaf01" }, "attrs": { "role": "leaf" } },
1130            { "uid": Uuid::from_u128(2).to_string(), "type": "dcim.device",
1131              "key": { "device": "spine01" }, "attrs": { "role": "spine" } },
1132            { "uid": Uuid::from_u128(3).to_string(), "type": "dcim.device",
1133              "key": { "device": "leaf02" }, "attrs": { "role": "leaf" } }
1134        ]));
1135        let out = compile_map(
1136            &input,
1137            &spec(
1138                r#"
1139schema:
1140  types:
1141    fabric.node:
1142      key:
1143        name: { type: slug }
1144rules:
1145  - name: non-leaves
1146    match: "dcim.device[attrs.role!=leaf]"
1147    emit:
1148      type: fabric.node
1149      key:
1150        name: "${key.device}"
1151"#,
1152            ),
1153        )
1154        .unwrap();
1155        let names: Vec<&str> = out
1156            .objects
1157            .iter()
1158            .map(|o| o.key.get("name").unwrap().as_str().unwrap())
1159            .collect();
1160        assert_eq!(names, vec!["spine01"]);
1161    }
1162
1163    #[test]
1164    fn predicate_coerces_numeric_and_bool_values() {
1165        let input = input_inventory(json!([
1166            { "uid": Uuid::from_u128(1).to_string(), "type": "ipam.vlan",
1167              "key": { "vid": 10 }, "attrs": { "enabled": true, "label": "v10" } },
1168            { "uid": Uuid::from_u128(2).to_string(), "type": "ipam.vlan",
1169              "key": { "vid": 20 }, "attrs": { "enabled": false, "label": "v20" } },
1170            { "uid": Uuid::from_u128(3).to_string(), "type": "ipam.vlan",
1171              "key": { "vid": 30 }, "attrs": { "enabled": true, "label": "v30" } }
1172        ]));
1173        let template = r#"
1174schema:
1175  types:
1176    fabric.vlan:
1177      key:
1178        name: { type: slug }
1179rules:
1180  - name: vlans
1181    match: "SELECTOR"
1182    emit:
1183      type: fabric.vlan
1184      key:
1185        name: "${attrs.label}"
1186"#;
1187        let names = |selector: &str| -> Vec<String> {
1188            compile_map(&input, &spec(&template.replace("SELECTOR", selector)))
1189                .unwrap()
1190                .objects
1191                .iter()
1192                .map(|o| o.key.get("name").unwrap().as_str().unwrap().to_string())
1193                .collect()
1194        };
1195        assert_eq!(names("ipam.vlan[key.vid=10]"), vec!["v10"]);
1196        assert_eq!(names("ipam.vlan[attrs.enabled=true]"), vec!["v10", "v30"]);
1197    }
1198
1199    #[test]
1200    fn multi_emit_fans_out_with_named_uid_reference() {
1201        // one source fabric fans out into a site and a vrf; the vrf references
1202        // the site via a named uid (auto ref-rewiring does not apply to
1203        // multi-emit, so the relationship is wired explicitly through `uids`).
1204        let input = input_inventory(json!([
1205            { "uid": Uuid::from_u128(1).to_string(), "type": "net.fabric",
1206              "key": { "fabric": "fra" }, "attrs": { "site": "fra1", "vrf": "blue" } }
1207        ]));
1208        let out = compile_map(
1209            &input,
1210            &spec(
1211                r#"
1212schema:
1213  types:
1214    location.site:
1215      key:
1216        slug: { type: slug }
1217    net.vrf:
1218      key:
1219        name: { type: slug }
1220      fields:
1221        site: { type: ref, target: location.site }
1222rules:
1223  - name: fabric
1224    match: "net.fabric"
1225    uids:
1226      site:
1227        v5:
1228          type: "location.site"
1229          stable: "slug=${attrs.site}"
1230    emit:
1231      - type: location.site
1232        key:
1233          slug: "${attrs.site}"
1234        uid: "${uids.site}"
1235      - type: net.vrf
1236        key:
1237          name: "${attrs.vrf}"
1238        attrs:
1239          site: "${uids.site}"
1240"#,
1241            ),
1242        )
1243        .unwrap();
1244
1245        assert_eq!(out.objects.len(), 2);
1246        let site = out
1247            .objects
1248            .iter()
1249            .find(|o| o.type_name.as_str() == "location.site")
1250            .unwrap();
1251        let vrf = out
1252            .objects
1253            .iter()
1254            .find(|o| o.type_name.as_str() == "net.vrf")
1255            .unwrap();
1256        // the named uid pins the site identity and the vrf ref resolves to it,
1257        // passing reference-integrity validation.
1258        assert_eq!(site.uid, uid_v5("location.site", "slug=fra1"));
1259        assert_eq!(vrf.attrs.get("site").unwrap(), &json!(site.uid.to_string()));
1260    }
1261
1262    #[test]
1263    fn group_by_aggregates_members_into_list_fields() {
1264        // many vlans collapse into one vrf per group; the members' vids are
1265        // collected into the vrf's `vlans` list.
1266        let input = input_inventory(json!([
1267            { "uid": Uuid::from_u128(1).to_string(), "type": "ipam.vlan",
1268              "key": { "vid": 10 }, "attrs": { "vrf": "blue" } },
1269            { "uid": Uuid::from_u128(2).to_string(), "type": "ipam.vlan",
1270              "key": { "vid": 20 }, "attrs": { "vrf": "blue" } },
1271            { "uid": Uuid::from_u128(3).to_string(), "type": "ipam.vlan",
1272              "key": { "vid": 30 }, "attrs": { "vrf": "red" } }
1273        ]));
1274        let out = compile_map(
1275            &input,
1276            &spec(
1277                r#"
1278schema:
1279  types:
1280    ipam.vrf:
1281      key:
1282        name: { type: slug }
1283      fields:
1284        vlans:
1285          type: list
1286          item: { type: int }
1287rules:
1288  - name: vrfs
1289    match: "ipam.vlan"
1290    group_by: "${attrs.vrf}"
1291    emit:
1292      type: ipam.vrf
1293      key:
1294        name: "${group.key}"
1295      attrs:
1296        vlans: "${group.items.key.vid}"
1297"#,
1298            ),
1299        )
1300        .unwrap();
1301
1302        // two groups (blue, red), sorted by key; members keep input order.
1303        assert_eq!(out.objects.len(), 2);
1304        let blue = out
1305            .objects
1306            .iter()
1307            .find(|o| o.key.get("name").unwrap() == &json!("blue"))
1308            .unwrap();
1309        let red = out
1310            .objects
1311            .iter()
1312            .find(|o| o.key.get("name").unwrap() == &json!("red"))
1313            .unwrap();
1314        assert_eq!(blue.attrs.get("vlans").unwrap(), &json!([10, 20]));
1315        assert_eq!(red.attrs.get("vlans").unwrap(), &json!([30]));
1316    }
1317
1318    #[test]
1319    fn group_by_excludes_null_member_values() {
1320        // a member whose value at the path is present-but-null is dropped from
1321        // the `group.items` list, matching the "non-missing values only" contract
1322        // (and the null-is-missing convention the predicates use).
1323        let input = input_inventory(json!([
1324            { "uid": Uuid::from_u128(1).to_string(), "type": "ipam.vlan",
1325              "key": { "vid": 10 }, "attrs": { "vrf": "blue", "name": "core" } },
1326            { "uid": Uuid::from_u128(2).to_string(), "type": "ipam.vlan",
1327              "key": { "vid": 20 }, "attrs": { "vrf": "blue", "name": null } }
1328        ]));
1329        let out = compile_map(
1330            &input,
1331            &spec(
1332                r#"
1333schema:
1334  types:
1335    ipam.vrf:
1336      key:
1337        name: { type: slug }
1338      fields:
1339        names: { type: json }
1340rules:
1341  - name: vrfs
1342    match: "ipam.vlan"
1343    group_by: "${attrs.vrf}"
1344    emit:
1345      type: ipam.vrf
1346      key:
1347        name: "${group.key}"
1348      attrs:
1349        names: "${group.items.attrs.name}"
1350"#,
1351            ),
1352        )
1353        .unwrap();
1354
1355        assert_eq!(out.objects.len(), 1);
1356        assert_eq!(out.objects[0].attrs.get("names").unwrap(), &json!(["core"]));
1357    }
1358
1359    #[test]
1360    fn lookup_reads_a_field_from_a_referenced_object() {
1361        // the device's `status` is a ref to a status object; the lookup follows
1362        // it and reads the referent's label, turning a ref into a string.
1363        let status_uid = Uuid::from_u128(9).to_string();
1364        let input = input_inventory(json!([
1365            { "uid": status_uid, "type": "extras.status",
1366              "key": { "name": "active" }, "attrs": { "label": "Active" } },
1367            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1368              "key": { "name": "leaf01" }, "attrs": { "status": status_uid } }
1369        ]));
1370        let out = compile_map(
1371            &input,
1372            &spec(
1373                r#"
1374schema:
1375  types:
1376    dcim.device:
1377      key:
1378        name: { type: slug }
1379      fields:
1380        status: { type: string }
1381rules:
1382  - name: devices
1383    match: "dcim.device"
1384    lookups:
1385      status_label:
1386        ref: "${attrs.status}"
1387        get: "attrs.label"
1388    emit:
1389      type: dcim.device
1390      key:
1391        name: "${key.name}"
1392      attrs:
1393        status: "${lookup.status_label}"
1394"#,
1395            ),
1396        )
1397        .unwrap();
1398
1399        assert_eq!(out.objects.len(), 1);
1400        assert_eq!(
1401            out.objects[0].attrs.get("status").unwrap(),
1402            &json!("Active")
1403        );
1404    }
1405
1406    /// the spec used by the three `resolve_lookup` failure tests: a single
1407    /// device rule following `attrs.status` and reading `attrs.label` off it.
1408    const LOOKUP_SPEC: &str = r#"
1409schema:
1410  types:
1411    dcim.device:
1412      key:
1413        name: { type: slug }
1414      fields:
1415        status: { type: string }
1416rules:
1417  - name: devices
1418    match: "dcim.device"
1419    lookups:
1420      status_label:
1421        ref: "${attrs.status}"
1422        get: "attrs.label"
1423    emit:
1424      type: dcim.device
1425      key:
1426        name: "${key.name}"
1427      attrs:
1428        status: "${lookup.status_label}"
1429"#;
1430
1431    #[test]
1432    fn lookup_ref_must_be_a_valid_uuid() {
1433        let input = input_inventory(json!([
1434            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1435              "key": { "name": "leaf01" }, "attrs": { "status": "not-a-uuid" } }
1436        ]));
1437        let err = compile_map(&input, &spec(LOOKUP_SPEC)).unwrap_err();
1438        assert!(
1439            err.to_string().contains("ref is not a valid uuid"),
1440            "{err:#}"
1441        );
1442    }
1443
1444    #[test]
1445    fn lookup_ref_must_be_present_in_the_input() {
1446        let absent = Uuid::from_u128(99).to_string();
1447        let input = input_inventory(json!([
1448            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1449              "key": { "name": "leaf01" }, "attrs": { "status": absent } }
1450        ]));
1451        let err = compile_map(&input, &spec(LOOKUP_SPEC)).unwrap_err();
1452        assert!(err.to_string().contains("is not in the input"), "{err:#}");
1453    }
1454
1455    #[test]
1456    fn lookup_get_field_must_exist_on_the_referent() {
1457        let status_uid = Uuid::from_u128(9).to_string();
1458        let input = input_inventory(json!([
1459            { "uid": status_uid, "type": "extras.status",
1460              "key": { "name": "active" }, "attrs": {} },
1461            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1462              "key": { "name": "leaf01" }, "attrs": { "status": status_uid } }
1463        ]));
1464        let err = compile_map(&input, &spec(LOOKUP_SPEC)).unwrap_err();
1465        assert!(err.to_string().contains("is absent on"), "{err:#}");
1466    }
1467
1468    #[test]
1469    fn conflicting_one_to_one_rules_on_one_source_error() {
1470        // two 1:1 rules match the same source but emit different target
1471        // identities, so the recorded source->target uid remap conflicts.
1472        let input = input_inventory(json!([
1473            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1474              "key": { "name": "leaf01" }, "attrs": {} }
1475        ]));
1476        let err = compile_map(
1477            &input,
1478            &spec(
1479                r#"
1480schema:
1481  types:
1482    a.node:
1483      key:
1484        name: { type: slug }
1485    b.node:
1486      key:
1487        name: { type: slug }
1488rules:
1489  - name: as
1490    match: "dcim.device"
1491    emit:
1492      type: a.node
1493      key:
1494        name: "${key.name}"
1495  - name: bs
1496    match: "dcim.device"
1497    emit:
1498      type: b.node
1499      key:
1500        name: "${key.name}"
1501"#,
1502            ),
1503        )
1504        .unwrap_err();
1505        assert!(
1506            err.to_string()
1507                .contains("matched by multiple rules emitting different uids"),
1508            "{err:#}"
1509        );
1510    }
1511
1512    #[test]
1513    fn emit_uid_template_must_be_a_valid_uuid() {
1514        let input = input_inventory(json!([
1515            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1516              "key": { "name": "leaf01" }, "attrs": {} }
1517        ]));
1518        let err = compile_map(
1519            &input,
1520            &spec(
1521                r#"
1522schema:
1523  types:
1524    lab.node:
1525      key:
1526        name: { type: slug }
1527rules:
1528  - name: nodes
1529    match: "dcim.device"
1530    emit:
1531      type: lab.node
1532      key:
1533        name: "${key.name}"
1534      uid: "not-a-uuid"
1535"#,
1536            ),
1537        )
1538        .unwrap_err();
1539        assert!(
1540            err.to_string().contains("uid template is not a valid uuid"),
1541            "{err:#}"
1542        );
1543    }
1544
1545    #[test]
1546    fn emit_uid_v5_rejects_empty_components() {
1547        // the `stable` component renders empty, tripping the shared
1548        // `derive_v5_uid` non-empty guard.
1549        let input = input_inventory(json!([
1550            { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1551              "key": { "name": "leaf01" }, "attrs": { "blank": "" } }
1552        ]));
1553        let err = compile_map(
1554            &input,
1555            &spec(
1556                r#"
1557schema:
1558  types:
1559    lab.node:
1560      key:
1561        name: { type: slug }
1562rules:
1563  - name: nodes
1564    match: "dcim.device"
1565    emit:
1566      type: lab.node
1567      key:
1568        name: "${key.name}"
1569      uid:
1570        v5:
1571          type: "lab.node"
1572          stable: "${attrs.blank}"
1573"#,
1574            ),
1575        )
1576        .unwrap_err();
1577        assert!(
1578            err.to_string().contains("non-empty type and stable"),
1579            "{err:#}"
1580        );
1581    }
1582
1583    #[cfg(not(feature = "starlark"))]
1584    #[test]
1585    fn transforms_block_errors_without_the_feature() {
1586        let input = input_inventory(json!([]));
1587        let err = compile_map(
1588            &input,
1589            &spec(
1590                r#"
1591transforms:
1592  inline: |
1593    def f(v):
1594        return v
1595"#,
1596            ),
1597        )
1598        .unwrap_err();
1599        assert!(
1600            err.to_string().contains("without the starlark feature"),
1601            "{err:#}"
1602        );
1603    }
1604
1605    #[cfg(feature = "starlark")]
1606    mod starlark {
1607        use super::*;
1608
1609        /// the motivating example: a netbox-shaped address with a cidr suffix
1610        /// denormalised into a connectable `ansible_host`.
1611        #[test]
1612        fn inline_transform_derives_attr_end_to_end() {
1613            let input = input_inventory(json!([
1614                { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1615                  "key": { "name": "leaf01" },
1616                  "attrs": { "address": "198.51.100.1/24", "platform": "nxos" } }
1617            ]));
1618            let out = compile_map(
1619                &input,
1620                &spec(
1621                    r#"
1622transforms:
1623  inline: |
1624    ANSIBLE_OS = {"nxos": "cisco.nxos.nxos", "eos": "arista.eos.eos"}
1625
1626    def cidr_host(v):
1627        return v.split("/")[0]
1628
1629    def ansible_os(platform):
1630        if platform not in ANSIBLE_OS:
1631            fail("no ansible_network_os mapping for platform: " + platform)
1632        return ANSIBLE_OS[platform]
1633schema:
1634  types:
1635    ansible.host:
1636      key:
1637        name: { type: string }
1638      fields:
1639        ansible_host: { type: string }
1640        ansible_network_os: { type: string }
1641rules:
1642  - name: hosts
1643    match: "dcim.device"
1644    emit:
1645      type: ansible.host
1646      key:
1647        name: "${key.name}"
1648      attrs:
1649        ansible_host: "${attrs.address|cidr_host}"
1650        ansible_network_os: "${attrs.platform|ansible_os}"
1651"#,
1652                ),
1653            )
1654            .unwrap();
1655            assert_eq!(out.objects.len(), 1);
1656            let attrs = &out.objects[0].attrs;
1657            assert_eq!(attrs.get("ansible_host").unwrap(), &json!("198.51.100.1"));
1658            assert_eq!(
1659                attrs.get("ansible_network_os").unwrap(),
1660                &json!("cisco.nxos.nxos")
1661            );
1662        }
1663
1664        /// a transform returning a dict fills a `json`-typed attr with the dict
1665        /// preserved, and passes schema validation.
1666        #[test]
1667        fn typed_dict_return_fills_a_json_attr() {
1668            let input = input_inventory(json!([
1669                { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1670                  "key": { "name": "leaf01" }, "attrs": { "platform": "eos" } }
1671            ]));
1672            let out = compile_map(
1673                &input,
1674                &spec(
1675                    r#"
1676transforms:
1677  inline: |
1678    def profile(platform):
1679        return {"os": platform, "ports": [22, 830]}
1680schema:
1681  types:
1682    lab.node:
1683      key:
1684        name: { type: string }
1685      fields:
1686        profile: { type: json }
1687rules:
1688  - name: nodes
1689    match: "dcim.device"
1690    emit:
1691      type: lab.node
1692      key:
1693        name: "${key.name}"
1694      attrs:
1695        profile: "${attrs.platform|profile}"
1696"#,
1697                ),
1698            )
1699            .unwrap();
1700            assert_eq!(
1701                out.objects[0].attrs.get("profile").unwrap(),
1702                &json!({"os": "eos", "ports": [22, 830]})
1703            );
1704        }
1705
1706        /// `key:` templates feed uid derivation, so a transformed value there is
1707        /// coerced to a string; a collection return is rejected.
1708        #[test]
1709        fn key_context_coerces_scalars_and_rejects_collections() {
1710            let input = input_inventory(json!([
1711                { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1712                  "key": { "name": "leaf01" }, "attrs": {} }
1713            ]));
1714            let scalar_spec = r#"
1715transforms:
1716  inline: |
1717    def n(v):
1718        return 42
1719schema:
1720  types:
1721    lab.node:
1722      key:
1723        name: { type: string }
1724rules:
1725  - name: nodes
1726    match: "dcim.device"
1727    emit:
1728      type: lab.node
1729      key:
1730        name: "${key.name|n}"
1731"#;
1732            let out = compile_map(&input, &spec(scalar_spec)).unwrap();
1733            assert_eq!(out.objects[0].key.get("name").unwrap(), &json!("42"));
1734
1735            let collection_spec = scalar_spec.replace("return 42", "return [v]");
1736            let err = compile_map(&input, &spec(&collection_spec)).unwrap_err();
1737            assert!(err.to_string().contains("must be a scalar"), "{err:#}");
1738        }
1739
1740        #[test]
1741        fn transforms_block_requires_exactly_one_source() {
1742            let input = input_inventory(json!([]));
1743            for block in [
1744                "transforms: {}",
1745                "transforms:\n  file: a.star\n  inline: \"x = 1\"",
1746            ] {
1747                let err = compile_map(&input, &spec(block)).unwrap_err();
1748                assert!(
1749                    err.to_string()
1750                        .contains("requires exactly one of file or inline"),
1751                    "{err:#}"
1752                );
1753            }
1754        }
1755
1756        /// a file-based transforms block whose script has a `load()` dependency,
1757        /// loaded the way the cli does: `load_map_spec` captures the spec
1758        /// directory, and both the transforms file and its `load()` target
1759        /// resolve against it.
1760        #[test]
1761        fn file_transforms_with_load_resolve_against_the_spec_dir() {
1762            let dir = tempfile::tempdir().unwrap();
1763            std::fs::write(
1764                dir.path().join("lib.star"),
1765                "def shout(v):\n    return v.upper()\n",
1766            )
1767            .unwrap();
1768            std::fs::write(
1769                dir.path().join("transforms.star"),
1770                "load(\"lib.star\", \"shout\")\n\ndef loud_host(v):\n    return shout(v.split(\"/\")[0])\n",
1771            )
1772            .unwrap();
1773            std::fs::write(
1774                dir.path().join("map.yaml"),
1775                r#"
1776transforms:
1777  file: ./transforms.star
1778schema:
1779  types:
1780    lab.node:
1781      key:
1782        name: { type: string }
1783rules:
1784  - name: nodes
1785    match: "dcim.device"
1786    emit:
1787      type: lab.node
1788      key:
1789        name: "${attrs.address|loud_host}"
1790"#,
1791            )
1792            .unwrap();
1793            let map_spec = load_map_spec(dir.path().join("map.yaml")).unwrap();
1794            let input = input_inventory(json!([
1795                { "uid": Uuid::from_u128(1).to_string(), "type": "dcim.device",
1796                  "key": { "name": "leaf01" }, "attrs": { "address": "leaf01/24" } }
1797            ]));
1798            let out = compile_map(&input, &map_spec).unwrap();
1799            assert_eq!(out.objects[0].key.get("name").unwrap(), &json!("LEAF01"));
1800        }
1801
1802        #[test]
1803        fn eval_map_transform_runs_user_builtin_and_errors() {
1804            let map_spec = spec(
1805                r#"
1806transforms:
1807  inline: |
1808    def pad(v, width, fill):
1809        return fill * (width - len(v)) + v
1810
1811    def reject(v):
1812        fail("rejected: " + v)
1813"#,
1814            );
1815            let result =
1816                eval_map_transform(&map_spec, "pad", &json!("7"), &[json!(3), json!("0")]).unwrap();
1817            assert_eq!(result, json!("007"));
1818
1819            let result = eval_map_transform(&map_spec, "upper", &json!("q"), &[]).unwrap();
1820            assert_eq!(result, json!("Q"));
1821
1822            let err = eval_map_transform(&map_spec, "reject", &json!("v"), &[]).unwrap_err();
1823            assert!(err.to_string().contains("rejected: v"), "{err:#}");
1824
1825            let err = eval_map_transform(&map_spec, "nope", &json!("v"), &[]).unwrap_err();
1826            assert!(
1827                err.to_string().contains("unknown transform nope"),
1828                "{err:#}"
1829            );
1830        }
1831    }
1832}