1use 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#[derive(Debug, Deserialize)]
34#[serde(untagged)]
35pub enum EmitUid {
36 V5 { v5: UidV5Spec },
37 Template(String),
38}
39
40#[derive(Debug, Deserialize)]
42pub struct MapSpec {
43 #[serde(default)]
45 pub schema: Schema,
46 #[serde(default)]
47 pub rules: Vec<MapRule>,
48 #[serde(default)]
51 pub transforms: Option<TransformsSpec>,
52 #[serde(skip)]
57 pub base_dir: Option<PathBuf>,
58}
59
60#[derive(Debug, Deserialize)]
63pub struct TransformsSpec {
64 #[serde(default)]
66 pub file: Option<PathBuf>,
67 #[serde(default)]
69 pub inline: Option<String>,
70}
71
72#[derive(Debug, Deserialize)]
77pub struct MapRule {
78 pub name: String,
79 pub r#match: String,
82 #[serde(default)]
87 pub group_by: Option<String>,
88 #[serde(default)]
92 pub lookups: BTreeMap<String, Lookup>,
93 #[serde(default)]
95 pub uids: BTreeMap<String, EmitUid>,
96 pub emit: EmitSpec,
98}
99
100#[derive(Debug, Deserialize)]
103pub struct Lookup {
104 pub r#ref: String,
105 pub get: String,
106}
107
108#[derive(Debug, Deserialize)]
110#[serde(untagged)]
111pub enum EmitSpec {
112 Keyword(EmitKeyword),
117 Single(MapEmit),
118 Multi(Vec<MapEmit>),
119}
120
121#[derive(Debug, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum EmitKeyword {
125 Passthrough,
126}
127
128#[derive(Debug, Deserialize)]
130pub struct MapEmit {
131 #[serde(rename = "type", alias = "kind")]
133 pub type_name: String,
134 pub key: BTreeMap<String, YamlValue>,
135 #[serde(default)]
137 pub uid: Option<EmitUid>,
138 #[serde(default)]
139 pub attrs: BTreeMap<String, YamlValue>,
140}
141
142struct Matcher {
147 glob: TypeGlob,
148 predicates: Vec<Predicate>,
149}
150
151enum 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
193fn 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
212fn json_scalar(value: &JsonValue) -> Option<String> {
215 crate::render::scalar_string(value)
216}
217
218pub 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
230fn 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
272pub 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(®istry, name, value, args)
284}
285
286struct MapRun<'a> {
289 transforms: TransformRegistry,
290 index: BTreeMap<Uid, &'a Object>,
291}
292
293pub fn compile_map(input: &Inventory, spec: &MapSpec) -> Result<Inventory> {
295 let run = MapRun {
296 transforms: transform_registry(spec)?,
297 index: input.objects.iter().map(|o| (o.uid, o)).collect(),
299 };
300 let mut objects = Vec::new();
301 let mut remap: BTreeMap<Uid, Uid> = BTreeMap::new();
303 let mut claimed: BTreeSet<Uid> = BTreeSet::new();
306 let mut passthrough_types: BTreeSet<String> = BTreeSet::new();
308
309 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 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 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 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 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 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
444fn 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 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 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
505fn 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
534fn 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
564fn 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
582fn 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
593fn 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
607fn 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
626fn 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
641fn 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 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 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 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 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 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 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 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 assert_eq!(iface.uid, uid_v5("dcim.interface", &key_string(&iface.key)));
1130 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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}