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