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