1use crate::error::FaucetError;
43#[cfg(any(
44 feature = "transform-flatten",
45 feature = "transform-rename-keys",
46 feature = "transform-keys-case",
47 feature = "transform-set",
48))]
49use serde_json::Map;
50use serde_json::Value;
51use std::fmt;
52use std::sync::Arc;
53
54#[cfg(any(
55 feature = "transform-cast",
56 feature = "transform-rename-field",
57 feature = "transform-value-case",
58 feature = "transform-spell-symbols",
59))]
60use std::collections::HashMap;
61
62#[cfg(feature = "transform-rename-keys")]
63use regex::Regex;
64
65#[cfg(feature = "transform-cast")]
74#[derive(
75 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
76)]
77#[serde(rename_all = "lowercase")]
78pub enum CastType {
79 Int,
81 Float,
83 Bool,
86 String,
88 Timestamp,
90}
91
92#[cfg(feature = "transform-cast")]
94#[derive(
95 Debug,
96 Clone,
97 Copy,
98 PartialEq,
99 Eq,
100 serde::Deserialize,
101 serde::Serialize,
102 schemars::JsonSchema,
103 Default,
104)]
105#[serde(rename_all = "lowercase")]
106pub enum CastOnError {
107 #[default]
109 Error,
110 Null,
112 Skip,
114}
115
116#[cfg(feature = "transform-keys-case")]
122#[derive(
123 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
124)]
125#[serde(rename_all = "snake_case")]
126pub enum KeyCaseMode {
127 Snake,
129 Camel,
132 Pascal,
134 Kebab,
136 ScreamingSnake,
138}
139
140#[cfg(feature = "transform-value-case")]
142#[derive(
143 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
144)]
145#[serde(rename_all = "lowercase")]
146pub enum ValueCaseMode {
147 Lower,
149 Upper,
151 Trim,
153}
154
155pub enum RecordTransform {
167 #[cfg(feature = "transform-flatten")]
180 Flatten { separator: String },
181
182 #[cfg(feature = "transform-rename-keys")]
197 RenameKeys {
198 pattern: String,
199 replacement: String,
200 },
201
202 #[cfg(feature = "transform-keys-case")]
217 KeysCase { mode: KeyCaseMode },
218
219 #[cfg(feature = "transform-select")]
226 Select { fields: Vec<String> },
227
228 #[cfg(feature = "transform-drop")]
234 Drop { fields: Vec<String> },
235
236 #[cfg(feature = "transform-set")]
243 Set { values: Map<String, Value> },
244
245 #[cfg(feature = "transform-rename-field")]
254 RenameField {
255 fields: HashMap<String, String>,
257 },
258
259 #[cfg(feature = "transform-cast")]
267 Cast {
268 fields: HashMap<String, CastType>,
269 on_error: CastOnError,
270 },
271
272 #[cfg(feature = "transform-redact")]
279 Redact { fields: Vec<String>, mask: Value },
280
281 #[cfg(feature = "transform-value-case")]
288 ValueCase {
289 fields: Vec<String>,
290 mode: ValueCaseMode,
291 },
292
293 #[cfg(feature = "transform-spell-symbols")]
314 SpellSymbols {
315 extra: HashMap<String, String>,
318 separator: String,
321 },
322
323 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
330}
331
332impl fmt::Debug for RecordTransform {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 match self {
335 #[cfg(feature = "transform-flatten")]
336 Self::Flatten { separator } => f
337 .debug_struct("Flatten")
338 .field("separator", separator)
339 .finish(),
340 #[cfg(feature = "transform-rename-keys")]
341 Self::RenameKeys {
342 pattern,
343 replacement,
344 } => f
345 .debug_struct("RenameKeys")
346 .field("pattern", pattern)
347 .field("replacement", replacement)
348 .finish(),
349 #[cfg(feature = "transform-keys-case")]
350 Self::KeysCase { mode } => f.debug_struct("KeysCase").field("mode", mode).finish(),
351 #[cfg(feature = "transform-select")]
352 Self::Select { fields } => f.debug_struct("Select").field("fields", fields).finish(),
353 #[cfg(feature = "transform-drop")]
354 Self::Drop { fields } => f.debug_struct("Drop").field("fields", fields).finish(),
355 #[cfg(feature = "transform-set")]
356 Self::Set { values } => f.debug_struct("Set").field("values", values).finish(),
357 #[cfg(feature = "transform-rename-field")]
358 Self::RenameField { fields } => f
359 .debug_struct("RenameField")
360 .field("fields", fields)
361 .finish(),
362 #[cfg(feature = "transform-cast")]
363 Self::Cast { fields, on_error } => f
364 .debug_struct("Cast")
365 .field("fields", fields)
366 .field("on_error", on_error)
367 .finish(),
368 #[cfg(feature = "transform-redact")]
369 Self::Redact { fields, mask } => f
370 .debug_struct("Redact")
371 .field("fields", fields)
372 .field("mask", mask)
373 .finish(),
374 #[cfg(feature = "transform-value-case")]
375 Self::ValueCase { fields, mode } => f
376 .debug_struct("ValueCase")
377 .field("fields", fields)
378 .field("mode", mode)
379 .finish(),
380 #[cfg(feature = "transform-spell-symbols")]
381 Self::SpellSymbols { extra, separator } => f
382 .debug_struct("SpellSymbols")
383 .field("extra", extra)
384 .field("separator", separator)
385 .finish(),
386 Self::Custom(_) => write!(f, "Custom(<fn>)"),
387 }
388 }
389}
390
391impl Clone for RecordTransform {
394 fn clone(&self) -> Self {
395 match self {
396 #[cfg(feature = "transform-flatten")]
397 Self::Flatten { separator } => Self::Flatten {
398 separator: separator.clone(),
399 },
400 #[cfg(feature = "transform-rename-keys")]
401 Self::RenameKeys {
402 pattern,
403 replacement,
404 } => Self::RenameKeys {
405 pattern: pattern.clone(),
406 replacement: replacement.clone(),
407 },
408 #[cfg(feature = "transform-keys-case")]
409 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
410 #[cfg(feature = "transform-select")]
411 Self::Select { fields } => Self::Select {
412 fields: fields.clone(),
413 },
414 #[cfg(feature = "transform-drop")]
415 Self::Drop { fields } => Self::Drop {
416 fields: fields.clone(),
417 },
418 #[cfg(feature = "transform-set")]
419 Self::Set { values } => Self::Set {
420 values: values.clone(),
421 },
422 #[cfg(feature = "transform-rename-field")]
423 Self::RenameField { fields } => Self::RenameField {
424 fields: fields.clone(),
425 },
426 #[cfg(feature = "transform-cast")]
427 Self::Cast { fields, on_error } => Self::Cast {
428 fields: fields.clone(),
429 on_error: *on_error,
430 },
431 #[cfg(feature = "transform-redact")]
432 Self::Redact { fields, mask } => Self::Redact {
433 fields: fields.clone(),
434 mask: mask.clone(),
435 },
436 #[cfg(feature = "transform-value-case")]
437 Self::ValueCase { fields, mode } => Self::ValueCase {
438 fields: fields.clone(),
439 mode: *mode,
440 },
441 #[cfg(feature = "transform-spell-symbols")]
442 Self::SpellSymbols { extra, separator } => Self::SpellSymbols {
443 extra: extra.clone(),
444 separator: separator.clone(),
445 },
446 Self::Custom(f) => Self::Custom(Arc::clone(f)),
447 }
448 }
449}
450
451impl Clone for CompiledTransform {
454 fn clone(&self) -> Self {
455 match self {
456 #[cfg(feature = "transform-flatten")]
457 Self::Flatten { separator } => Self::Flatten {
458 separator: separator.clone(),
459 },
460 #[cfg(feature = "transform-rename-keys")]
461 Self::RenameKeys { re, replacement } => Self::RenameKeys {
462 re: re.clone(),
463 replacement: replacement.clone(),
464 },
465 #[cfg(feature = "transform-keys-case")]
466 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
467 #[cfg(feature = "transform-select")]
468 Self::Select { fields } => Self::Select {
469 fields: fields.clone(),
470 },
471 #[cfg(feature = "transform-drop")]
472 Self::Drop { fields } => Self::Drop {
473 fields: fields.clone(),
474 },
475 #[cfg(feature = "transform-set")]
476 Self::Set { values } => Self::Set {
477 values: values.clone(),
478 },
479 #[cfg(feature = "transform-rename-field")]
480 Self::RenameField { fields } => Self::RenameField {
481 fields: fields.clone(),
482 },
483 #[cfg(feature = "transform-cast")]
484 Self::Cast { fields, on_error } => Self::Cast {
485 fields: fields.clone(),
486 on_error: *on_error,
487 },
488 #[cfg(feature = "transform-redact")]
489 Self::Redact { fields, mask } => Self::Redact {
490 fields: fields.clone(),
491 mask: mask.clone(),
492 },
493 #[cfg(feature = "transform-value-case")]
494 Self::ValueCase { fields, mode } => Self::ValueCase {
495 fields: fields.clone(),
496 mode: *mode,
497 },
498 #[cfg(feature = "transform-spell-symbols")]
499 Self::SpellSymbols {
500 replacements,
501 separator,
502 } => Self::SpellSymbols {
503 replacements: replacements.clone(),
504 separator: separator.clone(),
505 },
506 Self::Custom(f) => Self::Custom(Arc::clone(f)),
507 }
508 }
509}
510
511impl RecordTransform {
512 pub fn custom<F>(f: F) -> Self
536 where
537 F: Fn(Value) -> Value + Send + Sync + 'static,
538 {
539 Self::Custom(Arc::new(f))
540 }
541}
542
543pub enum CompiledTransform {
551 #[cfg(feature = "transform-flatten")]
552 Flatten {
553 separator: String,
554 },
555 #[cfg(feature = "transform-rename-keys")]
556 RenameKeys {
557 re: Regex,
558 replacement: String,
559 },
560 #[cfg(feature = "transform-keys-case")]
561 KeysCase {
562 mode: KeyCaseMode,
563 },
564 #[cfg(feature = "transform-select")]
565 Select {
566 fields: Vec<String>,
567 },
568 #[cfg(feature = "transform-drop")]
569 Drop {
570 fields: Vec<String>,
571 },
572 #[cfg(feature = "transform-set")]
573 Set {
574 values: Map<String, Value>,
575 },
576 #[cfg(feature = "transform-rename-field")]
577 RenameField {
578 fields: Vec<(String, String)>,
581 },
582 #[cfg(feature = "transform-cast")]
583 Cast {
584 fields: HashMap<String, CastType>,
585 on_error: CastOnError,
586 },
587 #[cfg(feature = "transform-redact")]
588 Redact {
589 fields: Vec<String>,
590 mask: Value,
591 },
592 #[cfg(feature = "transform-value-case")]
593 ValueCase {
594 fields: Vec<String>,
595 mode: ValueCaseMode,
596 },
597 #[cfg(feature = "transform-spell-symbols")]
598 SpellSymbols {
599 replacements: Vec<(String, String)>,
602 separator: String,
603 },
604 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
605}
606
607pub fn compile(t: &RecordTransform) -> Result<CompiledTransform, FaucetError> {
611 match t {
612 #[cfg(feature = "transform-flatten")]
613 RecordTransform::Flatten { separator } => Ok(CompiledTransform::Flatten {
614 separator: separator.clone(),
615 }),
616 #[cfg(feature = "transform-rename-keys")]
617 RecordTransform::RenameKeys {
618 pattern,
619 replacement,
620 } => {
621 let re = Regex::new(pattern)
622 .map_err(|e| FaucetError::Transform(format!("invalid regex '{pattern}': {e}")))?;
623 Ok(CompiledTransform::RenameKeys {
624 re,
625 replacement: replacement.clone(),
626 })
627 }
628 #[cfg(feature = "transform-keys-case")]
629 RecordTransform::KeysCase { mode } => Ok(CompiledTransform::KeysCase { mode: *mode }),
630 #[cfg(feature = "transform-select")]
631 RecordTransform::Select { fields } => Ok(CompiledTransform::Select {
632 fields: fields.clone(),
633 }),
634 #[cfg(feature = "transform-drop")]
635 RecordTransform::Drop { fields } => Ok(CompiledTransform::Drop {
636 fields: fields.clone(),
637 }),
638 #[cfg(feature = "transform-set")]
639 RecordTransform::Set { values } => Ok(CompiledTransform::Set {
640 values: values.clone(),
641 }),
642 #[cfg(feature = "transform-rename-field")]
643 RecordTransform::RenameField { fields } => {
644 let mut fields: Vec<(String, String)> =
649 fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
650 fields.sort();
651 Ok(CompiledTransform::RenameField { fields })
652 }
653 #[cfg(feature = "transform-cast")]
654 RecordTransform::Cast { fields, on_error } => Ok(CompiledTransform::Cast {
655 fields: fields.clone(),
656 on_error: *on_error,
657 }),
658 #[cfg(feature = "transform-redact")]
659 RecordTransform::Redact { fields, mask } => Ok(CompiledTransform::Redact {
660 fields: fields.clone(),
661 mask: mask.clone(),
662 }),
663 #[cfg(feature = "transform-value-case")]
664 RecordTransform::ValueCase { fields, mode } => Ok(CompiledTransform::ValueCase {
665 fields: fields.clone(),
666 mode: *mode,
667 }),
668 #[cfg(feature = "transform-spell-symbols")]
669 RecordTransform::SpellSymbols { extra, separator } => {
670 let mut merged = default_symbol_map();
673 for (k, v) in extra {
674 merged.insert(k.clone(), v.clone());
675 }
676 let mut replacements: Vec<(String, String)> = merged.into_iter().collect();
677 replacements.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
678 Ok(CompiledTransform::SpellSymbols {
679 replacements,
680 separator: separator.clone(),
681 })
682 }
683 RecordTransform::Custom(f) => Ok(CompiledTransform::Custom(Arc::clone(f))),
684 }
685}
686
687pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
693 let mut acc = record;
694 for t in transforms {
695 acc = apply_one(acc, t)?;
696 }
697 Ok(acc)
698}
699
700fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
701 match t {
702 #[cfg(feature = "transform-flatten")]
703 CompiledTransform::Flatten { separator } => flatten(value, separator),
704 #[cfg(feature = "transform-rename-keys")]
705 CompiledTransform::RenameKeys { re, replacement } => {
706 Ok(rename_keys(value, re, replacement))
707 }
708 #[cfg(feature = "transform-keys-case")]
709 CompiledTransform::KeysCase { mode } => keys_case(value, *mode),
710 #[cfg(feature = "transform-select")]
711 CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
712 #[cfg(feature = "transform-drop")]
713 CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
714 #[cfg(feature = "transform-set")]
715 CompiledTransform::Set { values } => Ok(set_fields(value, values)),
716 #[cfg(feature = "transform-rename-field")]
717 CompiledTransform::RenameField { fields } => rename_field(value, fields),
718 #[cfg(feature = "transform-cast")]
719 CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
720 #[cfg(feature = "transform-redact")]
721 CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
722 #[cfg(feature = "transform-value-case")]
723 CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
724 #[cfg(feature = "transform-spell-symbols")]
725 CompiledTransform::SpellSymbols {
726 replacements,
727 separator,
728 } => spell_symbols(value, replacements, separator),
729 CompiledTransform::Custom(f) => Ok(f(value)),
730 }
731}
732
733#[cfg(feature = "transform-flatten")]
736fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
737 match value {
738 Value::Object(_) => {
739 let mut out = Map::new();
740 flatten_into(value, "", separator, &mut out)?;
741 Ok(Value::Object(out))
742 }
743 other => Ok(other),
744 }
745}
746
747#[cfg(feature = "transform-flatten")]
748fn flatten_into(
749 value: Value,
750 prefix: &str,
751 separator: &str,
752 out: &mut Map<String, Value>,
753) -> Result<(), FaucetError> {
754 match value {
755 Value::Object(map) => {
756 for (k, v) in map {
757 let key = if prefix.is_empty() {
758 k
759 } else {
760 format!("{prefix}{separator}{k}")
761 };
762 flatten_into(v, &key, separator, out)?;
763 }
764 }
765 other => {
766 if out.contains_key(prefix) {
770 return Err(FaucetError::Transform(format!(
771 "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
772 to the same flattened key (separator '{separator}')"
773 )));
774 }
775 out.insert(prefix.to_string(), other);
776 }
777 }
778 Ok(())
779}
780
781#[cfg(feature = "transform-rename-keys")]
784fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Value {
785 match value {
786 Value::Object(map) => {
787 let new_map: Map<String, Value> = map
788 .into_iter()
789 .map(|(k, v)| {
790 let new_k = re.replace_all(&k, replacement).into_owned();
791 (new_k, rename_keys(v, re, replacement))
792 })
793 .collect();
794 Value::Object(new_map)
795 }
796 Value::Array(arr) => Value::Array(
797 arr.into_iter()
798 .map(|v| rename_keys(v, re, replacement))
799 .collect(),
800 ),
801 other => other,
802 }
803}
804
805#[cfg(feature = "transform-keys-case")]
809fn keys_case(value: Value, mode: KeyCaseMode) -> Result<Value, FaucetError> {
810 match value {
811 Value::Object(map) => {
812 let mut new_map = Map::with_capacity(map.len());
813 for (k, v) in map {
814 let tokens = tokenize_key(&k);
815 let recased = if tokens.is_empty() {
816 k
819 } else {
820 apply_key_case(tokens, mode)
821 };
822 let new_v = keys_case(v, mode)?;
823 if new_map.contains_key(&recased) {
824 return Err(FaucetError::Transform(format!(
825 "keys_case produced a duplicate key '{recased}'; two distinct keys \
826 re-case to the same name under mode {mode:?}"
827 )));
828 }
829 new_map.insert(recased, new_v);
830 }
831 Ok(Value::Object(new_map))
832 }
833 Value::Array(arr) => {
834 let mut out = Vec::with_capacity(arr.len());
835 for v in arr {
836 out.push(keys_case(v, mode)?);
837 }
838 Ok(Value::Array(out))
839 }
840 other => Ok(other),
841 }
842}
843
844#[cfg(feature = "transform-keys-case")]
850fn tokenize_key(key: &str) -> Vec<String> {
851 let mut tokens: Vec<String> = Vec::new();
852 let mut current = String::new();
853 let mut prev_was_lower = false;
854 for ch in key.chars() {
855 if ch.is_alphanumeric() {
856 if prev_was_lower && ch.is_uppercase() && !current.is_empty() {
857 tokens.push(std::mem::take(&mut current));
858 }
859 current.push(ch);
860 prev_was_lower = ch.is_lowercase();
861 } else {
862 if !current.is_empty() {
863 tokens.push(std::mem::take(&mut current));
864 }
865 prev_was_lower = false;
866 }
867 }
868 if !current.is_empty() {
869 tokens.push(current);
870 }
871 tokens
872}
873
874#[cfg(feature = "transform-keys-case")]
875fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
876 match mode {
877 KeyCaseMode::Snake => tokens
878 .iter()
879 .map(|t| t.to_lowercase())
880 .collect::<Vec<_>>()
881 .join("_"),
882 KeyCaseMode::ScreamingSnake => tokens
883 .iter()
884 .map(|t| t.to_uppercase())
885 .collect::<Vec<_>>()
886 .join("_"),
887 KeyCaseMode::Kebab => tokens
888 .iter()
889 .map(|t| t.to_lowercase())
890 .collect::<Vec<_>>()
891 .join("-"),
892 KeyCaseMode::Camel => {
893 let mut iter = tokens.into_iter();
894 match iter.next() {
895 None => String::new(),
896 Some(first) => {
897 let mut out = first.to_lowercase();
898 for t in iter {
899 out.push_str(&capitalize_token(&t));
900 }
901 out
902 }
903 }
904 }
905 KeyCaseMode::Pascal => tokens
906 .into_iter()
907 .map(|t| capitalize_token(&t))
908 .collect::<String>(),
909 }
910}
911
912#[cfg(feature = "transform-keys-case")]
914fn capitalize_token(s: &str) -> String {
915 let lower = s.to_lowercase();
916 let mut chars = lower.chars();
917 match chars.next() {
918 None => String::new(),
919 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
920 }
921}
922
923#[cfg(feature = "transform-select")]
926fn select_fields(value: Value, fields: &[String]) -> Value {
927 match value {
928 Value::Object(map) => {
929 let mut out = Map::with_capacity(fields.len().min(map.len()));
930 for f in fields {
932 if let Some(v) = map.get(f) {
933 out.insert(f.clone(), v.clone());
934 }
935 }
936 Value::Object(out)
937 }
938 other => other,
939 }
940}
941
942#[cfg(feature = "transform-drop")]
945fn drop_fields(value: Value, fields: &[String]) -> Value {
946 match value {
947 Value::Object(mut map) => {
948 for f in fields {
949 map.remove(f);
950 }
951 Value::Object(map)
952 }
953 other => other,
954 }
955}
956
957#[cfg(feature = "transform-set")]
960fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
961 match value {
962 Value::Object(mut map) => {
963 for (k, v) in values {
964 map.insert(k.clone(), v.clone());
965 }
966 Value::Object(map)
967 }
968 other => other,
969 }
970}
971
972#[cfg(feature = "transform-rename-field")]
975fn rename_field(value: Value, fields: &[(String, String)]) -> Result<Value, FaucetError> {
976 match value {
977 Value::Object(mut map) => {
978 let renames: Vec<(&str, &str)> = fields
984 .iter()
985 .filter(|(from, to)| from != to && map.contains_key(from))
986 .map(|(from, to)| (from.as_str(), to.as_str()))
987 .collect();
988 let sources: std::collections::HashSet<&str> =
989 renames.iter().map(|(from, _)| *from).collect();
990
991 let mut seen_targets: std::collections::HashSet<&str> =
993 std::collections::HashSet::new();
994 for (from, to) in &renames {
995 if !seen_targets.insert(to) {
996 return Err(FaucetError::Transform(format!(
997 "rename_field: two fields rename to the same target key '{to}'"
998 )));
999 }
1000 if map.contains_key(*to) && !sources.contains(to) {
1004 return Err(FaucetError::Transform(format!(
1005 "rename_field: target key '{to}' already exists on the record \
1006 (renaming from '{from}')"
1007 )));
1008 }
1009 }
1010
1011 let staged: Vec<(String, Value)> = renames
1012 .iter()
1013 .map(|(from, to)| {
1014 let v = map.remove(*from).expect("source presence checked above");
1015 (to.to_string(), v)
1016 })
1017 .collect();
1018 for (to, v) in staged {
1019 map.insert(to, v);
1020 }
1021 Ok(Value::Object(map))
1022 }
1023 other => Ok(other),
1024 }
1025}
1026
1027#[cfg(feature = "transform-cast")]
1030fn cast_fields(
1031 value: Value,
1032 fields: &HashMap<String, CastType>,
1033 on_error: CastOnError,
1034) -> Result<Value, FaucetError> {
1035 match value {
1036 Value::Object(mut map) => {
1037 for (field, target) in fields {
1038 let Some(current) = map.get(field) else {
1039 continue;
1040 };
1041 match cast_value(current, *target) {
1042 Ok(new_val) => {
1043 map.insert(field.clone(), new_val);
1044 }
1045 Err(msg) => match on_error {
1046 CastOnError::Error => {
1047 return Err(FaucetError::Transform(format!(
1048 "cast: field '{field}' to {target:?} failed: {msg}"
1049 )));
1050 }
1051 CastOnError::Null => {
1052 map.insert(field.clone(), Value::Null);
1053 }
1054 CastOnError::Skip => { }
1055 },
1056 }
1057 }
1058 Ok(Value::Object(map))
1059 }
1060 other => Ok(other),
1061 }
1062}
1063
1064#[cfg(feature = "transform-cast")]
1067fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1068 match target {
1069 CastType::Int => match v {
1070 Value::Number(n) => {
1071 if let Some(i) = n.as_i64() {
1072 return Ok(Value::Number(i.into()));
1073 }
1074 match n.as_f64() {
1081 Some(f)
1082 if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1083 {
1084 Ok(Value::Number((f as i64).into()))
1085 }
1086 Some(f) => Err(format!(
1087 "float '{f}' is not a whole number representable as i64"
1088 )),
1089 None => Err(format!("number '{n}' is not representable as i64")),
1090 }
1091 }
1092 Value::String(s) => s
1093 .trim()
1094 .parse::<i64>()
1095 .map(|i| Value::Number(i.into()))
1096 .map_err(|e| format!("'{s}' is not an integer: {e}")),
1097 Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1098 Value::Null => Err("null cannot be cast to int".to_owned()),
1099 Value::Array(_) | Value::Object(_) => {
1100 Err("composite values cannot be cast to int".to_owned())
1101 }
1102 },
1103 CastType::Float => match v {
1104 Value::Number(n) => n
1105 .as_f64()
1106 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1107 .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1108 Value::String(s) => s
1109 .trim()
1110 .parse::<f64>()
1111 .ok()
1112 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1113 .ok_or_else(|| format!("'{s}' is not a float")),
1114 Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1115 .map(Value::Number)
1116 .ok_or_else(|| "could not encode bool as f64".to_owned()),
1117 Value::Null => Err("null cannot be cast to float".to_owned()),
1118 Value::Array(_) | Value::Object(_) => {
1119 Err("composite values cannot be cast to float".to_owned())
1120 }
1121 },
1122 CastType::Bool => match v {
1123 Value::Bool(b) => Ok(Value::Bool(*b)),
1124 Value::Number(n) => {
1125 if let Some(i) = n.as_i64() {
1126 match i {
1127 0 => Ok(Value::Bool(false)),
1128 1 => Ok(Value::Bool(true)),
1129 _ => Err(format!("integer {i} is not 0 or 1")),
1130 }
1131 } else {
1132 Err(format!("number '{n}' is not 0 or 1"))
1133 }
1134 }
1135 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
1136 "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
1137 "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
1138 other => Err(format!("'{other}' is not a recognised boolean")),
1139 },
1140 Value::Null => Err("null cannot be cast to bool".to_owned()),
1141 Value::Array(_) | Value::Object(_) => {
1142 Err("composite values cannot be cast to bool".to_owned())
1143 }
1144 },
1145 CastType::String => match v {
1146 Value::String(s) => Ok(Value::String(s.clone())),
1147 Value::Number(n) => Ok(Value::String(n.to_string())),
1148 Value::Bool(b) => Ok(Value::String(b.to_string())),
1149 Value::Null => Err("null cannot be cast to string".to_owned()),
1150 Value::Array(_) | Value::Object(_) => {
1151 Err("composite values cannot be cast to string".to_owned())
1152 }
1153 },
1154 CastType::Timestamp => match v {
1155 Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
1156 .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
1157 .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
1158 other => Err(format!(
1159 "cannot cast {} to timestamp (expected RFC 3339 string)",
1160 value_type_name(other)
1161 )),
1162 },
1163 }
1164}
1165
1166#[cfg(feature = "transform-cast")]
1167fn value_type_name(v: &Value) -> &'static str {
1168 match v {
1169 Value::Null => "null",
1170 Value::Bool(_) => "bool",
1171 Value::Number(_) => "number",
1172 Value::String(_) => "string",
1173 Value::Array(_) => "array",
1174 Value::Object(_) => "object",
1175 }
1176}
1177
1178#[cfg(feature = "transform-redact")]
1181fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
1182 match value {
1183 Value::Object(mut map) => {
1184 for f in fields {
1185 if map.contains_key(f) {
1186 map.insert(f.clone(), mask.clone());
1187 }
1188 }
1189 Value::Object(map)
1190 }
1191 other => other,
1192 }
1193}
1194
1195#[cfg(feature = "transform-value-case")]
1198fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
1199 match value {
1200 Value::Object(mut map) => {
1201 for f in fields {
1202 if let Some(Value::String(s)) = map.get(f) {
1203 let new_s = match mode {
1204 ValueCaseMode::Lower => s.to_lowercase(),
1205 ValueCaseMode::Upper => s.to_uppercase(),
1206 ValueCaseMode::Trim => s.trim().to_owned(),
1207 };
1208 map.insert(f.clone(), Value::String(new_s));
1209 }
1210 }
1211 Value::Object(map)
1212 }
1213 other => other,
1214 }
1215}
1216
1217#[cfg(feature = "transform-spell-symbols")]
1228pub fn default_symbol_map() -> HashMap<String, String> {
1229 let pairs: &[(&str, &str)] = &[
1230 ("%", "percent"),
1231 ("#", "number"),
1232 ("$", "dollar"),
1233 ("&", "and"),
1234 ("@", "at"),
1235 ("+", "plus"),
1236 ("*", "star"),
1237 ("=", "equals"),
1238 ("<", "lt"),
1239 (">", "gt"),
1240 ("/", "slash"),
1241 ("\\", "backslash"),
1242 ("|", "pipe"),
1243 ("^", "caret"),
1244 ("~", "tilde"),
1245 ];
1246 pairs
1247 .iter()
1248 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1249 .collect()
1250}
1251
1252#[cfg(feature = "transform-spell-symbols")]
1253fn spell_symbols(
1254 value: Value,
1255 replacements: &[(String, String)],
1256 separator: &str,
1257) -> Result<Value, FaucetError> {
1258 match value {
1259 Value::Object(map) => {
1260 let mut new_map = Map::with_capacity(map.len());
1261 for (k, v) in map {
1262 let new_k = spell_symbols_in_key(&k, replacements, separator);
1263 let new_v = spell_symbols(v, replacements, separator)?;
1264 if new_map.contains_key(&new_k) {
1268 return Err(FaucetError::Transform(format!(
1269 "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
1270 expand to the same name"
1271 )));
1272 }
1273 new_map.insert(new_k, new_v);
1274 }
1275 Ok(Value::Object(new_map))
1276 }
1277 Value::Array(arr) => {
1278 let mut out = Vec::with_capacity(arr.len());
1279 for v in arr {
1280 out.push(spell_symbols(v, replacements, separator)?);
1281 }
1282 Ok(Value::Array(out))
1283 }
1284 other => Ok(other),
1285 }
1286}
1287
1288#[cfg(feature = "transform-spell-symbols")]
1292fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
1293 let bytes = key.as_bytes();
1297 let mut out = String::with_capacity(key.len());
1298 let mut i = 0;
1299 while i < bytes.len() {
1300 let mut matched = false;
1301 for (from, to) in replacements {
1302 let f = from.as_bytes();
1303 if !f.is_empty() && bytes[i..].starts_with(f) {
1304 out.push_str(separator);
1305 out.push_str(to);
1306 out.push_str(separator);
1307 i += f.len();
1308 matched = true;
1309 break;
1310 }
1311 }
1312 if !matched {
1313 let ch = key[i..]
1316 .chars()
1317 .next()
1318 .expect("non-empty slice yields at least one char");
1319 out.push(ch);
1320 i += ch.len_utf8();
1321 }
1322 }
1323 out
1324}
1325
1326#[cfg(test)]
1329mod tests {
1330 use super::*;
1331 use serde_json::json;
1332
1333 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
1338 super::apply_all(record, transforms).expect("transform should succeed in this test")
1339 }
1340
1341 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
1342 transforms.iter().map(|t| compile(t).unwrap()).collect()
1343 }
1344
1345 #[test]
1348 fn test_custom_adds_field() {
1349 let record = json!({"id": 1});
1350 let result = apply_all(
1351 record,
1352 &compiled(&[RecordTransform::custom(|mut v| {
1353 if let Value::Object(ref mut m) = v {
1354 m.insert("added".to_string(), json!(true));
1355 }
1356 v
1357 })]),
1358 );
1359 assert_eq!(result["id"], 1);
1360 assert_eq!(result["added"], true);
1361 }
1362
1363 #[test]
1364 fn test_custom_removes_field() {
1365 let record = json!({"id": 1, "secret": "drop_me"});
1366 let result = apply_all(
1367 record,
1368 &compiled(&[RecordTransform::custom(|mut v| {
1369 if let Value::Object(ref mut m) = v {
1370 m.remove("secret");
1371 }
1372 v
1373 })]),
1374 );
1375 assert_eq!(result["id"], 1);
1376 assert!(result.get("secret").is_none());
1377 }
1378
1379 #[test]
1380 fn test_no_transforms_is_identity() {
1381 let record = json!({"id": 1, "name": "Alice"});
1382 let result = apply_all(record.clone(), &[]);
1383 assert_eq!(result, record);
1384 }
1385
1386 #[cfg(feature = "transform-flatten")]
1389 #[test]
1390 fn test_flatten_nested_object() {
1391 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
1392 let result = apply_all(
1393 record,
1394 &compiled(&[RecordTransform::Flatten {
1395 separator: "__".into(),
1396 }]),
1397 );
1398 assert_eq!(result["a__b"], 1);
1399 assert_eq!(result["a__c__d"], 2);
1400 assert_eq!(result["e"], 3);
1401 assert!(result.get("a").is_none(), "nested key should be removed");
1402 }
1403
1404 #[cfg(feature = "transform-flatten")]
1405 #[test]
1406 fn test_flatten_leaves_arrays_intact() {
1407 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
1408 let result = apply_all(
1409 record,
1410 &compiled(&[RecordTransform::Flatten {
1411 separator: ".".into(),
1412 }]),
1413 );
1414 assert_eq!(result["tags"], json!(["rust", "api"]));
1415 assert_eq!(result["meta.count"], 2);
1416 }
1417
1418 #[cfg(feature = "transform-flatten")]
1419 #[test]
1420 fn test_flatten_already_flat() {
1421 let record = json!({"id": 1, "name": "Alice"});
1422 let result = apply_all(
1423 record.clone(),
1424 &compiled(&[RecordTransform::Flatten {
1425 separator: "__".into(),
1426 }]),
1427 );
1428 assert_eq!(result, record);
1429 }
1430
1431 #[cfg(feature = "transform-flatten")]
1432 #[test]
1433 fn test_flatten_empty_separator() {
1434 let record = json!({"a": {"b": 1}});
1435 let result = apply_all(
1436 record,
1437 &compiled(&[RecordTransform::Flatten {
1438 separator: "".into(),
1439 }]),
1440 );
1441 assert_eq!(result["ab"], 1);
1442 }
1443
1444 #[cfg(feature = "transform-rename-keys")]
1447 #[test]
1448 fn test_rename_keys_strips_prefix() {
1449 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
1450 let result = apply_all(
1451 record,
1452 &compiled(&[RecordTransform::RenameKeys {
1453 pattern: r"^_prefix_".into(),
1454 replacement: "".into(),
1455 }]),
1456 );
1457 assert_eq!(result["id"], 1);
1458 assert_eq!(result["name"], "Alice");
1459 }
1460
1461 #[cfg(feature = "transform-rename-keys")]
1462 #[test]
1463 fn test_rename_keys_uppercase_to_placeholder() {
1464 let record = json!({"OUTER": {"INNER": 42}});
1465 let result = apply_all(
1466 record,
1467 &compiled(&[RecordTransform::RenameKeys {
1468 pattern: r"[A-Z]+".into(),
1469 replacement: "x".into(),
1470 }]),
1471 );
1472 assert_eq!(result["x"]["x"], 42);
1473 }
1474
1475 #[cfg(feature = "transform-rename-keys")]
1476 #[test]
1477 fn test_rename_keys_in_array_elements() {
1478 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
1479 let result = apply_all(
1480 record,
1481 &compiled(&[RecordTransform::RenameKeys {
1482 pattern: r"KEY".into(),
1483 replacement: "key".into(),
1484 }]),
1485 );
1486 assert_eq!(result["items"][0]["key"], 1);
1487 assert_eq!(result["items"][1]["key"], 2);
1488 }
1489
1490 #[cfg(feature = "transform-rename-keys")]
1491 #[test]
1492 fn test_rename_keys_invalid_regex_errors_at_compile() {
1493 let err = compile(&RecordTransform::RenameKeys {
1494 pattern: "[invalid".into(),
1495 replacement: "".into(),
1496 });
1497 assert!(err.is_err());
1498 assert!(matches!(err, Err(FaucetError::Transform(_))));
1499 }
1500
1501 #[cfg(feature = "transform-rename-keys")]
1502 #[test]
1503 fn test_rename_keys_chained() {
1504 let record = json!({"__camelCase__": 1});
1505 let result = apply_all(
1506 record,
1507 &compiled(&[
1508 RecordTransform::RenameKeys {
1509 pattern: r"^_+|_+$".into(),
1510 replacement: "".into(),
1511 },
1512 RecordTransform::RenameKeys {
1513 pattern: r"[A-Z]".into(),
1514 replacement: "_".into(),
1515 },
1516 ]),
1517 );
1518 let key = result.as_object().unwrap().keys().next().unwrap().clone();
1519 assert_eq!(key, "camel_ase");
1520 }
1521
1522 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
1525 #[test]
1526 fn test_keys_case_then_flatten() {
1527 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
1528 let result = apply_all(
1529 record,
1530 &compiled(&[
1531 RecordTransform::KeysCase {
1532 mode: KeyCaseMode::Snake,
1533 },
1534 RecordTransform::Flatten {
1535 separator: "_".into(),
1536 },
1537 ]),
1538 );
1539 assert_eq!(result["user_info_first_name"], "Alice");
1540 assert_eq!(result["user_info_last_name"], "Smith");
1541 }
1542
1543 #[test]
1544 fn test_custom_chained_with_builtin() {
1545 let record = json!({"id": 1, "raw_value": 100});
1547 let result = apply_all(
1548 record,
1549 &compiled(&[
1550 RecordTransform::custom(|mut v| {
1552 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
1553 && let Value::Object(ref mut m) = v
1554 {
1555 m.insert("raw_value".to_string(), json!(n * 2));
1556 }
1557 v
1558 }),
1559 RecordTransform::custom(|mut v| {
1561 if let Value::Object(ref mut m) = v
1562 && let Some(val) = m.remove("raw_value")
1563 {
1564 m.insert("value".to_string(), val);
1565 }
1566 v
1567 }),
1568 ]),
1569 );
1570 assert_eq!(result["id"], 1);
1571 assert_eq!(result["value"], 200);
1572 assert!(result.get("raw_value").is_none());
1573 }
1574
1575 #[cfg(feature = "transform-flatten")]
1578 #[test]
1579 fn flatten_key_collision_errors() {
1580 let record = json!({"a__b": 1, "a": {"b": 2}});
1582 let err = super::apply_all(
1583 record,
1584 &compiled(&[RecordTransform::Flatten {
1585 separator: "__".into(),
1586 }]),
1587 )
1588 .expect_err("colliding flattened keys must error, not drop a value");
1589 assert!(matches!(err, FaucetError::Transform(_)));
1590 assert!(format!("{err}").contains("a__b"), "{err}");
1591 }
1592
1593 #[cfg(feature = "transform-select")]
1596 #[test]
1597 fn select_keeps_only_listed_fields() {
1598 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
1599 let result = apply_all(
1600 record,
1601 &compiled(&[RecordTransform::Select {
1602 fields: vec!["id".into(), "name".into()],
1603 }]),
1604 );
1605 assert_eq!(result["id"], 1);
1606 assert_eq!(result["name"], "Alice");
1607 assert!(result.get("secret").is_none());
1608 }
1609
1610 #[cfg(feature = "transform-select")]
1611 #[test]
1612 fn select_missing_field_is_no_op() {
1613 let record = json!({"id": 1});
1615 let result = apply_all(
1616 record,
1617 &compiled(&[RecordTransform::Select {
1618 fields: vec!["id".into(), "missing".into()],
1619 }]),
1620 );
1621 assert_eq!(result["id"], 1);
1622 assert!(result.get("missing").is_none());
1623 }
1624
1625 #[cfg(feature = "transform-select")]
1626 #[test]
1627 fn select_passes_through_non_object() {
1628 let record = json!([1, 2, 3]);
1629 let result = apply_all(
1630 record.clone(),
1631 &compiled(&[RecordTransform::Select {
1632 fields: vec!["id".into()],
1633 }]),
1634 );
1635 assert_eq!(result, record);
1636 }
1637
1638 #[cfg(feature = "transform-drop")]
1641 #[test]
1642 fn drop_removes_listed_fields() {
1643 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
1644 let result = apply_all(
1645 record,
1646 &compiled(&[RecordTransform::Drop {
1647 fields: vec!["ssn".into()],
1648 }]),
1649 );
1650 assert_eq!(result["id"], 1);
1651 assert_eq!(result["name"], "Alice");
1652 assert!(result.get("ssn").is_none());
1653 }
1654
1655 #[cfg(feature = "transform-drop")]
1656 #[test]
1657 fn drop_missing_field_is_no_op() {
1658 let record = json!({"id": 1});
1659 let result = apply_all(
1660 record,
1661 &compiled(&[RecordTransform::Drop {
1662 fields: vec!["missing".into()],
1663 }]),
1664 );
1665 assert_eq!(result["id"], 1);
1666 }
1667
1668 #[cfg(feature = "transform-set")]
1671 #[test]
1672 fn set_inserts_new_fields() {
1673 let record = json!({"id": 1});
1674 let mut values = Map::new();
1675 values.insert("_source".into(), json!("api"));
1676 values.insert("ingested_at".into(), json!("2026-01-01"));
1677 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1678 assert_eq!(result["id"], 1);
1679 assert_eq!(result["_source"], "api");
1680 assert_eq!(result["ingested_at"], "2026-01-01");
1681 }
1682
1683 #[cfg(feature = "transform-set")]
1684 #[test]
1685 fn set_overwrites_existing_field() {
1686 let record = json!({"_source": "old", "id": 1});
1687 let mut values = Map::new();
1688 values.insert("_source".into(), json!("new"));
1689 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1690 assert_eq!(result["_source"], "new");
1691 assert_eq!(result["id"], 1);
1692 }
1693
1694 #[cfg(feature = "transform-set")]
1695 #[test]
1696 fn set_supports_any_json_value() {
1697 let record = json!({});
1698 let mut values = Map::new();
1699 values.insert("n".into(), json!(42));
1700 values.insert("b".into(), json!(true));
1701 values.insert("arr".into(), json!([1, 2]));
1702 values.insert("obj".into(), json!({"k": "v"}));
1703 values.insert("null".into(), Value::Null);
1704 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1705 assert_eq!(result["n"], 42);
1706 assert_eq!(result["b"], true);
1707 assert_eq!(result["arr"], json!([1, 2]));
1708 assert_eq!(result["obj"]["k"], "v");
1709 assert_eq!(result["null"], Value::Null);
1710 }
1711
1712 #[cfg(feature = "transform-rename-field")]
1715 #[test]
1716 fn rename_field_renames_exact_key() {
1717 let record = json!({"old_name": 1, "keep": 2});
1718 let mut fields = HashMap::new();
1719 fields.insert("old_name".to_owned(), "new_name".to_owned());
1720 let result = apply_all(
1721 record,
1722 &compiled(&[RecordTransform::RenameField { fields }]),
1723 );
1724 assert_eq!(result["new_name"], 1);
1725 assert_eq!(result["keep"], 2);
1726 assert!(result.get("old_name").is_none());
1727 }
1728
1729 #[cfg(feature = "transform-rename-field")]
1730 #[test]
1731 fn rename_field_missing_source_is_no_op() {
1732 let record = json!({"id": 1});
1733 let mut fields = HashMap::new();
1734 fields.insert("missing".to_owned(), "renamed".to_owned());
1735 let result = apply_all(
1736 record,
1737 &compiled(&[RecordTransform::RenameField { fields }]),
1738 );
1739 assert_eq!(result["id"], 1);
1740 assert!(result.get("renamed").is_none());
1741 }
1742
1743 #[cfg(feature = "transform-rename-field")]
1744 #[test]
1745 fn rename_field_target_collision_errors() {
1746 let record = json!({"a": 1, "b": 2});
1747 let mut fields = HashMap::new();
1748 fields.insert("a".to_owned(), "b".to_owned());
1749 let err = super::apply_all(
1750 record,
1751 &compiled(&[RecordTransform::RenameField { fields }]),
1752 )
1753 .expect_err("collision must error, not overwrite");
1754 assert!(matches!(err, FaucetError::Transform(_)));
1755 assert!(format!("{err}").contains("'b'"), "{err}");
1756 }
1757
1758 #[cfg(feature = "transform-rename-field")]
1759 #[test]
1760 fn rename_field_swap_is_deterministic() {
1761 for _ in 0..50 {
1764 let record = json!({"a": 1, "b": 2, "keep": 3});
1765 let mut fields = HashMap::new();
1766 fields.insert("a".to_owned(), "b".to_owned());
1767 fields.insert("b".to_owned(), "a".to_owned());
1768 let result = apply_all(
1769 record,
1770 &compiled(&[RecordTransform::RenameField { fields }]),
1771 );
1772 assert_eq!(result["a"], 2, "{result}");
1773 assert_eq!(result["b"], 1, "{result}");
1774 assert_eq!(result["keep"], 3);
1775 }
1776 }
1777
1778 #[cfg(feature = "transform-rename-field")]
1779 #[test]
1780 fn rename_field_chain_applies_against_original_snapshot() {
1781 for _ in 0..50 {
1784 let record = json!({"a": 1, "b": 2});
1785 let mut fields = HashMap::new();
1786 fields.insert("a".to_owned(), "b".to_owned());
1787 fields.insert("b".to_owned(), "c".to_owned());
1788 let result = apply_all(
1789 record,
1790 &compiled(&[RecordTransform::RenameField { fields }]),
1791 );
1792 assert_eq!(result["b"], 1, "{result}");
1793 assert_eq!(result["c"], 2, "{result}");
1794 assert!(result.get("a").is_none(), "{result}");
1795 }
1796 }
1797
1798 #[cfg(feature = "transform-rename-field")]
1799 #[test]
1800 fn rename_field_two_sources_one_target_errors() {
1801 let record = json!({"a": 1, "b": 2});
1802 let mut fields = HashMap::new();
1803 fields.insert("a".to_owned(), "c".to_owned());
1804 fields.insert("b".to_owned(), "c".to_owned());
1805 let err = super::apply_all(
1806 record,
1807 &compiled(&[RecordTransform::RenameField { fields }]),
1808 )
1809 .expect_err("two renames to the same target must error");
1810 assert!(format!("{err}").contains("same target"), "{err}");
1811 }
1812
1813 #[cfg(feature = "transform-cast")]
1816 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
1817 let mut fields = HashMap::new();
1818 fields.insert(field.to_owned(), ty);
1819 vec![RecordTransform::Cast { fields, on_error }]
1820 }
1821
1822 #[cfg(feature = "transform-cast")]
1823 #[test]
1824 fn cast_string_to_int() {
1825 let record = json!({"age": "42"});
1826 let result = apply_all(
1827 record,
1828 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
1829 );
1830 assert_eq!(result["age"], 42);
1831 }
1832
1833 #[cfg(feature = "transform-cast")]
1834 #[test]
1835 fn cast_whole_number_float_to_int_succeeds() {
1836 let record = json!({"n": 5.0});
1838 let result = apply_all(
1839 record,
1840 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1841 );
1842 assert_eq!(result["n"], 5);
1843 }
1844
1845 #[cfg(feature = "transform-cast")]
1846 #[test]
1847 fn cast_fractional_float_to_int_errors_under_on_error_error() {
1848 let record = json!({"n": 3.9});
1850 let err = super::apply_all(
1851 record,
1852 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1853 )
1854 .expect_err("a fractional float must not silently truncate to int");
1855 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
1856 }
1857
1858 #[cfg(feature = "transform-cast")]
1859 #[test]
1860 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
1861 let record = json!({"n": 1e30});
1863 let err = super::apply_all(
1864 record,
1865 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1866 )
1867 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
1868 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
1869 }
1870
1871 #[cfg(feature = "transform-cast")]
1872 #[test]
1873 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
1874 let record = json!({"n": 3.9});
1875 let result = apply_all(
1876 record,
1877 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
1878 );
1879 assert_eq!(result["n"], Value::Null);
1880 }
1881
1882 #[cfg(feature = "transform-cast")]
1883 #[test]
1884 fn cast_string_to_float() {
1885 let record = json!({"price": "9.99"});
1886 let result = apply_all(
1887 record,
1888 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
1889 );
1890 assert_eq!(result["price"], 9.99);
1891 }
1892
1893 #[cfg(feature = "transform-cast")]
1894 #[test]
1895 fn cast_string_to_bool() {
1896 for input in ["true", "TRUE", "1", "yes"] {
1897 let record = json!({"flag": input});
1898 let result = apply_all(
1899 record,
1900 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
1901 );
1902 assert_eq!(result["flag"], true, "input was {input:?}");
1903 }
1904 for input in ["false", "0", "no"] {
1905 let record = json!({"flag": input});
1906 let result = apply_all(
1907 record,
1908 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
1909 );
1910 assert_eq!(result["flag"], false, "input was {input:?}");
1911 }
1912 }
1913
1914 #[cfg(feature = "transform-cast")]
1915 #[test]
1916 fn cast_number_to_string() {
1917 let record = json!({"id": 42});
1918 let result = apply_all(
1919 record,
1920 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
1921 );
1922 assert_eq!(result["id"], "42");
1923 }
1924
1925 #[cfg(feature = "transform-cast")]
1926 #[test]
1927 fn cast_string_to_timestamp_normalises() {
1928 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
1929 let result = apply_all(
1930 record,
1931 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
1932 );
1933 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
1935 }
1936
1937 #[cfg(feature = "transform-cast")]
1938 #[test]
1939 fn cast_on_error_error_propagates() {
1940 let record = json!({"age": "not a number"});
1941 let err = super::apply_all(
1942 record,
1943 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
1944 )
1945 .expect_err("uncastable value must error under on_error=error");
1946 assert!(matches!(err, FaucetError::Transform(_)));
1947 assert!(format!("{err}").contains("'age'"), "{err}");
1948 }
1949
1950 #[cfg(feature = "transform-cast")]
1951 #[test]
1952 fn cast_on_error_null_replaces() {
1953 let record = json!({"age": "not a number"});
1954 let result = apply_all(
1955 record,
1956 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
1957 );
1958 assert_eq!(result["age"], Value::Null);
1959 }
1960
1961 #[cfg(feature = "transform-cast")]
1962 #[test]
1963 fn cast_on_error_skip_leaves_value() {
1964 let record = json!({"age": "not a number"});
1965 let result = apply_all(
1966 record,
1967 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
1968 );
1969 assert_eq!(result["age"], "not a number");
1970 }
1971
1972 #[cfg(feature = "transform-cast")]
1973 #[test]
1974 fn cast_missing_field_is_no_op() {
1975 let record = json!({"id": 1});
1976 let result = apply_all(
1977 record,
1978 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
1979 );
1980 assert_eq!(result["id"], 1);
1981 assert!(result.get("missing").is_none());
1982 }
1983
1984 #[cfg(feature = "transform-redact")]
1987 #[test]
1988 fn redact_replaces_value_with_mask() {
1989 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
1990 let result = apply_all(
1991 record,
1992 &compiled(&[RecordTransform::Redact {
1993 fields: vec!["ssn".into(), "email".into()],
1994 mask: json!("***"),
1995 }]),
1996 );
1997 assert_eq!(result["id"], 1);
1998 assert_eq!(result["ssn"], "***");
1999 assert_eq!(result["email"], "***");
2000 }
2001
2002 #[cfg(feature = "transform-redact")]
2003 #[test]
2004 fn redact_missing_field_does_not_insert_mask() {
2005 let record = json!({"id": 1});
2006 let result = apply_all(
2007 record,
2008 &compiled(&[RecordTransform::Redact {
2009 fields: vec!["ssn".into()],
2010 mask: json!("***"),
2011 }]),
2012 );
2013 assert_eq!(result["id"], 1);
2014 assert!(result.get("ssn").is_none());
2015 }
2016
2017 #[cfg(feature = "transform-value-case")]
2020 #[test]
2021 fn value_case_lower() {
2022 let record = json!({"email": "User@Example.COM", "id": 1});
2023 let result = apply_all(
2024 record,
2025 &compiled(&[RecordTransform::ValueCase {
2026 fields: vec!["email".into()],
2027 mode: ValueCaseMode::Lower,
2028 }]),
2029 );
2030 assert_eq!(result["email"], "user@example.com");
2031 assert_eq!(result["id"], 1);
2032 }
2033
2034 #[cfg(feature = "transform-value-case")]
2035 #[test]
2036 fn value_case_upper() {
2037 let record = json!({"code": "abc"});
2038 let result = apply_all(
2039 record,
2040 &compiled(&[RecordTransform::ValueCase {
2041 fields: vec!["code".into()],
2042 mode: ValueCaseMode::Upper,
2043 }]),
2044 );
2045 assert_eq!(result["code"], "ABC");
2046 }
2047
2048 #[cfg(feature = "transform-value-case")]
2049 #[test]
2050 fn value_case_trim() {
2051 let record = json!({"name": " Alice "});
2052 let result = apply_all(
2053 record,
2054 &compiled(&[RecordTransform::ValueCase {
2055 fields: vec!["name".into()],
2056 mode: ValueCaseMode::Trim,
2057 }]),
2058 );
2059 assert_eq!(result["name"], "Alice");
2060 }
2061
2062 #[cfg(feature = "transform-value-case")]
2063 #[test]
2064 fn value_case_passes_non_string_through() {
2065 let record = json!({"id": 42});
2066 let result = apply_all(
2067 record,
2068 &compiled(&[RecordTransform::ValueCase {
2069 fields: vec!["id".into()],
2070 mode: ValueCaseMode::Upper,
2071 }]),
2072 );
2073 assert_eq!(result["id"], 42);
2074 }
2075
2076 #[cfg(feature = "transform-spell-symbols")]
2079 fn spell_default() -> Vec<RecordTransform> {
2080 vec![RecordTransform::SpellSymbols {
2081 extra: HashMap::new(),
2082 separator: " ".into(),
2083 }]
2084 }
2085
2086 #[cfg(feature = "transform-spell-symbols")]
2087 #[test]
2088 fn spell_symbols_replaces_common_symbols() {
2089 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
2090 let result = apply_all(record, &compiled(&spell_default()));
2091 assert!(result.get(" percent sold").is_some());
2094 assert!(result.get("C number course").is_some());
2095 assert!(result.get(" dollar amount").is_some());
2096 }
2097
2098 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
2099 #[test]
2100 fn spell_symbols_then_keys_case_pipeline() {
2101 let record = json!({"% sold": 10, "C# courses": 20});
2102 let result = super::apply_all(
2103 record,
2104 &compiled(&[
2105 RecordTransform::SpellSymbols {
2106 extra: HashMap::new(),
2107 separator: " ".into(),
2108 },
2109 RecordTransform::KeysCase {
2110 mode: KeyCaseMode::Snake,
2111 },
2112 ]),
2113 )
2114 .expect("pipeline must succeed");
2115 assert_eq!(result["percent_sold"], 10);
2116 assert_eq!(result["c_number_courses"], 20);
2117 }
2118
2119 #[cfg(feature = "transform-spell-symbols")]
2120 #[test]
2121 fn spell_symbols_extra_overrides_defaults() {
2122 let mut extra = HashMap::new();
2123 extra.insert("#".to_owned(), "hash".to_owned());
2124 extra.insert("©".to_owned(), "copyright".to_owned());
2125 let record = json!({"#tag": 1, "©2026": 2});
2126 let result = apply_all(
2127 record,
2128 &compiled(&[RecordTransform::SpellSymbols {
2129 extra,
2130 separator: " ".into(),
2131 }]),
2132 );
2133 assert!(result.get(" hash tag").is_some());
2135 assert!(result.get(" copyright 2026").is_some());
2137 }
2138
2139 #[cfg(feature = "transform-spell-symbols")]
2140 #[test]
2141 fn spell_symbols_longest_match_wins() {
2142 let mut extra = HashMap::new();
2144 extra.insert("<=".to_owned(), "lte".to_owned());
2145 let record = json!({"a<=b": 1});
2146 let result = apply_all(
2147 record,
2148 &compiled(&[RecordTransform::SpellSymbols {
2149 extra,
2150 separator: " ".into(),
2151 }]),
2152 );
2153 assert!(result.get("a lte b").is_some());
2154 assert!(result.get("a lt = b").is_none());
2156 }
2157
2158 #[cfg(feature = "transform-spell-symbols")]
2159 #[test]
2160 fn spell_symbols_recursive_into_objects_and_arrays() {
2161 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
2162 let result = apply_all(record, &compiled(&spell_default()));
2163 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
2164 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
2165 let inner = &result[&outer_key];
2166 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
2167 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
2168 let deep = &inner[&inner_key][0];
2169 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
2170 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
2171 }
2172
2173 #[cfg(feature = "transform-spell-symbols")]
2174 #[test]
2175 fn spell_symbols_key_collision_errors() {
2176 let record = json!({"%": 1, "percent": 2});
2178 let err = super::apply_all(
2179 record,
2180 &compiled(&[RecordTransform::SpellSymbols {
2181 extra: HashMap::new(),
2182 separator: "".into(),
2183 }]),
2184 )
2185 .expect_err("colliding spelled keys must error, not drop a value");
2186 assert!(matches!(err, FaucetError::Transform(_)));
2187 assert!(format!("{err}").contains("percent"), "{err}");
2188 }
2189
2190 #[cfg(feature = "transform-keys-case")]
2193 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
2194 vec![RecordTransform::KeysCase { mode }]
2195 }
2196
2197 #[cfg(feature = "transform-keys-case")]
2198 #[test]
2199 fn keys_case_snake() {
2200 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
2201 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2202 assert_eq!(result["first_name"], 1);
2203 assert_eq!(result["last_name"], 2);
2204 assert_eq!(result["id"], 3);
2205 }
2206
2207 #[cfg(feature = "transform-keys-case")]
2208 #[test]
2209 fn keys_case_camel_from_various_inputs() {
2210 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
2212 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
2213 assert_eq!(result["firstName"], 1);
2214 assert_eq!(result["userId"], 2);
2215 assert_eq!(result["kebabCase"], 3);
2216 assert_eq!(result["pascalCase"], 4);
2217 }
2218
2219 #[cfg(feature = "transform-keys-case")]
2220 #[test]
2221 fn keys_case_pascal() {
2222 let record = json!({"first_name": 1, "second name": 2});
2223 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
2224 assert_eq!(result["FirstName"], 1);
2225 assert_eq!(result["SecondName"], 2);
2226 }
2227
2228 #[cfg(feature = "transform-keys-case")]
2229 #[test]
2230 fn keys_case_kebab() {
2231 let record = json!({"firstName": 1, "second_name": 2});
2232 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
2233 assert_eq!(result["first-name"], 1);
2234 assert_eq!(result["second-name"], 2);
2235 }
2236
2237 #[cfg(feature = "transform-keys-case")]
2238 #[test]
2239 fn keys_case_screaming_snake() {
2240 let record = json!({"firstName": 1, "second name": 2});
2241 let result = apply_all(
2242 record,
2243 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
2244 );
2245 assert_eq!(result["FIRST_NAME"], 1);
2246 assert_eq!(result["SECOND_NAME"], 2);
2247 }
2248
2249 #[cfg(feature = "transform-keys-case")]
2250 #[test]
2251 fn keys_case_recursive_into_nested() {
2252 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
2253 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2254 assert_eq!(result["user_info"]["first_name"], "Alice");
2255 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
2256 }
2257
2258 #[cfg(feature = "transform-keys-case")]
2259 #[test]
2260 fn keys_case_collision_errors() {
2261 let record = json!({"firstName": 1, "first_name": 2});
2263 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
2264 .expect_err("colliding re-cased keys must error, not drop a value");
2265 assert!(matches!(err, FaucetError::Transform(_)));
2266 assert!(format!("{err}").contains("first_name"), "{err}");
2267 }
2268
2269 #[cfg(feature = "transform-keys-case")]
2270 #[test]
2271 fn keys_case_all_symbol_key_kept_as_is() {
2272 let record = json!({"!@#": 1, "id": 2});
2275 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2276 assert_eq!(result["!@#"], 1);
2277 assert_eq!(result["id"], 2);
2278 }
2279
2280 #[cfg(feature = "transform-keys-case")]
2281 #[test]
2282 fn keys_case_idempotent_in_target_mode() {
2283 let record = json!({"first_name": 1});
2286 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2287 let twice = apply_all(
2288 once.clone(),
2289 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
2290 );
2291 assert_eq!(once, twice);
2292 }
2293
2294 #[cfg(feature = "transform-spell-symbols")]
2295 #[test]
2296 fn spell_symbols_handles_unicode_keys() {
2297 let record = json!({"café%": 1});
2299 let result = apply_all(record, &compiled(&spell_default()));
2300 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2301 assert!(key.contains("café"), "key was {key:?}");
2302 assert!(key.contains("percent"), "key was {key:?}");
2303 }
2304
2305 #[test]
2308 fn debug_record_transform_all_variants() {
2309 let dbg = format!("{:?}", RecordTransform::custom(|v| v));
2311 assert_eq!(dbg, "Custom(<fn>)");
2312
2313 #[cfg(feature = "transform-flatten")]
2314 {
2315 let dbg = format!(
2316 "{:?}",
2317 RecordTransform::Flatten {
2318 separator: "__".into()
2319 }
2320 );
2321 assert!(dbg.starts_with("Flatten"), "{dbg}");
2322 assert!(dbg.contains("separator"), "{dbg}");
2323 assert!(dbg.contains("__"), "{dbg}");
2324 }
2325 #[cfg(feature = "transform-rename-keys")]
2326 {
2327 let dbg = format!(
2328 "{:?}",
2329 RecordTransform::RenameKeys {
2330 pattern: "p".into(),
2331 replacement: "r".into(),
2332 }
2333 );
2334 assert!(dbg.starts_with("RenameKeys"), "{dbg}");
2335 assert!(dbg.contains("pattern"), "{dbg}");
2336 assert!(dbg.contains("replacement"), "{dbg}");
2337 }
2338 #[cfg(feature = "transform-keys-case")]
2339 {
2340 let dbg = format!(
2341 "{:?}",
2342 RecordTransform::KeysCase {
2343 mode: KeyCaseMode::Snake
2344 }
2345 );
2346 assert!(dbg.starts_with("KeysCase"), "{dbg}");
2347 assert!(dbg.contains("Snake"), "{dbg}");
2348 }
2349 #[cfg(feature = "transform-select")]
2350 {
2351 let dbg = format!(
2352 "{:?}",
2353 RecordTransform::Select {
2354 fields: vec!["a".into()]
2355 }
2356 );
2357 assert!(dbg.starts_with("Select"), "{dbg}");
2358 assert!(dbg.contains("fields"), "{dbg}");
2359 }
2360 #[cfg(feature = "transform-drop")]
2361 {
2362 let dbg = format!(
2363 "{:?}",
2364 RecordTransform::Drop {
2365 fields: vec!["a".into()]
2366 }
2367 );
2368 assert!(dbg.starts_with("Drop"), "{dbg}");
2369 }
2370 #[cfg(feature = "transform-set")]
2371 {
2372 let mut values = Map::new();
2373 values.insert("k".into(), json!("v"));
2374 let dbg = format!("{:?}", RecordTransform::Set { values });
2375 assert!(dbg.starts_with("Set"), "{dbg}");
2376 assert!(dbg.contains("values"), "{dbg}");
2377 }
2378 #[cfg(feature = "transform-rename-field")]
2379 {
2380 let mut fields = HashMap::new();
2381 fields.insert("a".to_owned(), "b".to_owned());
2382 let dbg = format!("{:?}", RecordTransform::RenameField { fields });
2383 assert!(dbg.starts_with("RenameField"), "{dbg}");
2384 }
2385 #[cfg(feature = "transform-cast")]
2386 {
2387 let mut fields = HashMap::new();
2388 fields.insert("a".to_owned(), CastType::Int);
2389 let dbg = format!(
2390 "{:?}",
2391 RecordTransform::Cast {
2392 fields,
2393 on_error: CastOnError::Error,
2394 }
2395 );
2396 assert!(dbg.starts_with("Cast"), "{dbg}");
2397 assert!(dbg.contains("on_error"), "{dbg}");
2398 }
2399 #[cfg(feature = "transform-redact")]
2400 {
2401 let dbg = format!(
2402 "{:?}",
2403 RecordTransform::Redact {
2404 fields: vec!["a".into()],
2405 mask: json!("***"),
2406 }
2407 );
2408 assert!(dbg.starts_with("Redact"), "{dbg}");
2409 assert!(dbg.contains("mask"), "{dbg}");
2410 }
2411 #[cfg(feature = "transform-value-case")]
2412 {
2413 let dbg = format!(
2414 "{:?}",
2415 RecordTransform::ValueCase {
2416 fields: vec!["a".into()],
2417 mode: ValueCaseMode::Lower,
2418 }
2419 );
2420 assert!(dbg.starts_with("ValueCase"), "{dbg}");
2421 assert!(dbg.contains("mode"), "{dbg}");
2422 }
2423 #[cfg(feature = "transform-spell-symbols")]
2424 {
2425 let dbg = format!(
2426 "{:?}",
2427 RecordTransform::SpellSymbols {
2428 extra: HashMap::new(),
2429 separator: " ".into(),
2430 }
2431 );
2432 assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
2433 assert!(dbg.contains("separator"), "{dbg}");
2434 }
2435 }
2436
2437 #[test]
2440 fn clone_record_transform_custom_preserves_behaviour() {
2441 let original = RecordTransform::custom(|mut v| {
2442 if let Value::Object(ref mut m) = v {
2443 m.insert("cloned".into(), json!(true));
2444 }
2445 v
2446 });
2447 let cloned = original.clone();
2448 assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
2449 let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
2450 assert_eq!(out["cloned"], true);
2451 assert_eq!(out["id"], 1);
2452 }
2453
2454 #[test]
2455 #[allow(clippy::vec_init_then_push)]
2458 fn clone_record_transform_all_builtin_variants() {
2459 let mut variants: Vec<RecordTransform> = Vec::new();
2460 #[cfg(feature = "transform-flatten")]
2461 variants.push(RecordTransform::Flatten {
2462 separator: "__".into(),
2463 });
2464 #[cfg(feature = "transform-rename-keys")]
2465 variants.push(RecordTransform::RenameKeys {
2466 pattern: "p".into(),
2467 replacement: "r".into(),
2468 });
2469 #[cfg(feature = "transform-keys-case")]
2470 variants.push(RecordTransform::KeysCase {
2471 mode: KeyCaseMode::Snake,
2472 });
2473 #[cfg(feature = "transform-select")]
2474 variants.push(RecordTransform::Select {
2475 fields: vec!["a".into()],
2476 });
2477 #[cfg(feature = "transform-drop")]
2478 variants.push(RecordTransform::Drop {
2479 fields: vec!["a".into()],
2480 });
2481 #[cfg(feature = "transform-set")]
2482 {
2483 let mut values = Map::new();
2484 values.insert("k".into(), json!("v"));
2485 variants.push(RecordTransform::Set { values });
2486 }
2487 #[cfg(feature = "transform-rename-field")]
2488 {
2489 let mut fields = HashMap::new();
2490 fields.insert("a".to_owned(), "b".to_owned());
2491 variants.push(RecordTransform::RenameField { fields });
2492 }
2493 #[cfg(feature = "transform-cast")]
2494 {
2495 let mut fields = HashMap::new();
2496 fields.insert("a".to_owned(), CastType::Int);
2497 variants.push(RecordTransform::Cast {
2498 fields,
2499 on_error: CastOnError::Error,
2500 });
2501 }
2502 #[cfg(feature = "transform-redact")]
2503 variants.push(RecordTransform::Redact {
2504 fields: vec!["a".into()],
2505 mask: json!("***"),
2506 });
2507 #[cfg(feature = "transform-value-case")]
2508 variants.push(RecordTransform::ValueCase {
2509 fields: vec!["a".into()],
2510 mode: ValueCaseMode::Lower,
2511 });
2512 #[cfg(feature = "transform-spell-symbols")]
2513 variants.push(RecordTransform::SpellSymbols {
2514 extra: HashMap::new(),
2515 separator: " ".into(),
2516 });
2517
2518 for v in &variants {
2520 let cloned = v.clone();
2521 assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
2522 }
2523 }
2524
2525 #[test]
2526 fn clone_compiled_transform_all_variants() {
2527 let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
2528 #[cfg(feature = "transform-flatten")]
2529 specs.push(RecordTransform::Flatten {
2530 separator: "__".into(),
2531 });
2532 #[cfg(feature = "transform-rename-keys")]
2533 specs.push(RecordTransform::RenameKeys {
2534 pattern: "p".into(),
2535 replacement: "r".into(),
2536 });
2537 #[cfg(feature = "transform-keys-case")]
2538 specs.push(RecordTransform::KeysCase {
2539 mode: KeyCaseMode::Camel,
2540 });
2541 #[cfg(feature = "transform-select")]
2542 specs.push(RecordTransform::Select {
2543 fields: vec!["a".into()],
2544 });
2545 #[cfg(feature = "transform-drop")]
2546 specs.push(RecordTransform::Drop {
2547 fields: vec!["a".into()],
2548 });
2549 #[cfg(feature = "transform-set")]
2550 {
2551 let mut values = Map::new();
2552 values.insert("k".into(), json!("v"));
2553 specs.push(RecordTransform::Set { values });
2554 }
2555 #[cfg(feature = "transform-rename-field")]
2556 {
2557 let mut fields = HashMap::new();
2558 fields.insert("a".to_owned(), "b".to_owned());
2559 specs.push(RecordTransform::RenameField { fields });
2560 }
2561 #[cfg(feature = "transform-cast")]
2562 {
2563 let mut fields = HashMap::new();
2564 fields.insert("a".to_owned(), CastType::Int);
2565 specs.push(RecordTransform::Cast {
2566 fields,
2567 on_error: CastOnError::Null,
2568 });
2569 }
2570 #[cfg(feature = "transform-redact")]
2571 specs.push(RecordTransform::Redact {
2572 fields: vec!["a".into()],
2573 mask: json!("***"),
2574 });
2575 #[cfg(feature = "transform-value-case")]
2576 specs.push(RecordTransform::ValueCase {
2577 fields: vec!["a".into()],
2578 mode: ValueCaseMode::Upper,
2579 });
2580 #[cfg(feature = "transform-spell-symbols")]
2581 specs.push(RecordTransform::SpellSymbols {
2582 extra: HashMap::new(),
2583 separator: " ".into(),
2584 });
2585
2586 let original = compiled(&specs);
2589 let cloned: Vec<CompiledTransform> = original.to_vec();
2590 assert_eq!(original.len(), cloned.len());
2591 let record = json!({"a": "1", "k": "x"});
2592 let out_orig = super::apply_all(record.clone(), &original);
2593 let out_clone = super::apply_all(record, &cloned);
2594 assert_eq!(
2595 out_orig.is_ok(),
2596 out_clone.is_ok(),
2597 "clone must transform identically"
2598 );
2599 if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
2600 assert_eq!(a, b);
2601 }
2602 }
2603
2604 #[cfg(feature = "transform-flatten")]
2607 #[test]
2608 fn flatten_passes_through_non_object() {
2609 let record = json!([1, 2, 3]);
2610 let result = apply_all(
2611 record.clone(),
2612 &compiled(&[RecordTransform::Flatten {
2613 separator: "__".into(),
2614 }]),
2615 );
2616 assert_eq!(result, record);
2617 let scalar = json!(42);
2619 let result = apply_all(
2620 scalar.clone(),
2621 &compiled(&[RecordTransform::Flatten {
2622 separator: "__".into(),
2623 }]),
2624 );
2625 assert_eq!(result, scalar);
2626 }
2627
2628 #[cfg(feature = "transform-drop")]
2629 #[test]
2630 fn drop_passes_through_non_object() {
2631 let record = json!([1, 2]);
2632 let result = apply_all(
2633 record.clone(),
2634 &compiled(&[RecordTransform::Drop {
2635 fields: vec!["a".into()],
2636 }]),
2637 );
2638 assert_eq!(result, record);
2639 }
2640
2641 #[cfg(feature = "transform-set")]
2642 #[test]
2643 fn set_passes_through_non_object() {
2644 let mut values = Map::new();
2645 values.insert("k".into(), json!("v"));
2646 let record = json!("scalar");
2647 let result = apply_all(
2648 record.clone(),
2649 &compiled(&[RecordTransform::Set { values }]),
2650 );
2651 assert_eq!(result, record);
2652 }
2653
2654 #[cfg(feature = "transform-rename-field")]
2655 #[test]
2656 fn rename_field_passes_through_non_object() {
2657 let mut fields = HashMap::new();
2658 fields.insert("a".to_owned(), "b".to_owned());
2659 let record = json!([1, 2]);
2660 let result = apply_all(
2661 record.clone(),
2662 &compiled(&[RecordTransform::RenameField { fields }]),
2663 );
2664 assert_eq!(result, record);
2665 }
2666
2667 #[cfg(feature = "transform-rename-field")]
2668 #[test]
2669 fn rename_field_same_name_is_skipped() {
2670 let mut fields = HashMap::new();
2672 fields.insert("a".to_owned(), "a".to_owned());
2673 let record = json!({"a": 1});
2674 let result = apply_all(
2675 record,
2676 &compiled(&[RecordTransform::RenameField { fields }]),
2677 );
2678 assert_eq!(result["a"], 1);
2679 }
2680
2681 #[cfg(feature = "transform-cast")]
2682 #[test]
2683 fn cast_passes_through_non_object() {
2684 let record = json!([1, 2]);
2685 let result = apply_all(
2686 record.clone(),
2687 &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
2688 );
2689 assert_eq!(result, record);
2690 }
2691
2692 #[cfg(feature = "transform-redact")]
2693 #[test]
2694 fn redact_passes_through_non_object() {
2695 let record = json!("scalar");
2696 let result = apply_all(
2697 record.clone(),
2698 &compiled(&[RecordTransform::Redact {
2699 fields: vec!["a".into()],
2700 mask: json!("***"),
2701 }]),
2702 );
2703 assert_eq!(result, record);
2704 }
2705
2706 #[cfg(feature = "transform-value-case")]
2707 #[test]
2708 fn value_case_passes_through_non_object() {
2709 let record = json!([1, 2]);
2710 let result = apply_all(
2711 record.clone(),
2712 &compiled(&[RecordTransform::ValueCase {
2713 fields: vec!["a".into()],
2714 mode: ValueCaseMode::Lower,
2715 }]),
2716 );
2717 assert_eq!(result, record);
2718 }
2719
2720 #[cfg(feature = "transform-cast")]
2723 #[test]
2724 fn cast_integer_number_to_int_is_identity() {
2725 let record = json!({"n": 7});
2727 let result = apply_all(
2728 record,
2729 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2730 );
2731 assert_eq!(result["n"], 7);
2732 }
2733
2734 #[cfg(feature = "transform-cast")]
2735 #[test]
2736 fn cast_bool_to_int() {
2737 let record = json!({"t": true, "f": false});
2738 let mut fields = HashMap::new();
2739 fields.insert("t".to_owned(), CastType::Int);
2740 fields.insert("f".to_owned(), CastType::Int);
2741 let result = apply_all(
2742 record,
2743 &compiled(&[RecordTransform::Cast {
2744 fields,
2745 on_error: CastOnError::Error,
2746 }]),
2747 );
2748 assert_eq!(result["t"], 1);
2749 assert_eq!(result["f"], 0);
2750 }
2751
2752 #[cfg(feature = "transform-cast")]
2753 #[test]
2754 fn cast_null_to_int_errors() {
2755 let record = json!({"n": null});
2756 let err = super::apply_all(
2757 record,
2758 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2759 )
2760 .expect_err("null cannot become int");
2761 assert!(
2762 format!("{err}").contains("null cannot be cast to int"),
2763 "{err}"
2764 );
2765 }
2766
2767 #[cfg(feature = "transform-cast")]
2768 #[test]
2769 fn cast_composite_to_int_errors() {
2770 let record = json!({"n": [1, 2]});
2771 let err = super::apply_all(
2772 record,
2773 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2774 )
2775 .expect_err("array cannot become int");
2776 assert!(format!("{err}").contains("composite"), "{err}");
2777 }
2778
2779 #[cfg(feature = "transform-cast")]
2780 #[test]
2781 fn cast_number_to_float() {
2782 let record = json!({"n": 5});
2783 let result = apply_all(
2784 record,
2785 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2786 );
2787 assert_eq!(result["n"], 5.0);
2788 }
2789
2790 #[cfg(feature = "transform-cast")]
2791 #[test]
2792 fn cast_bool_to_float() {
2793 let record = json!({"t": true, "f": false});
2794 let mut fields = HashMap::new();
2795 fields.insert("t".to_owned(), CastType::Float);
2796 fields.insert("f".to_owned(), CastType::Float);
2797 let result = apply_all(
2798 record,
2799 &compiled(&[RecordTransform::Cast {
2800 fields,
2801 on_error: CastOnError::Error,
2802 }]),
2803 );
2804 assert_eq!(result["t"], 1.0);
2805 assert_eq!(result["f"], 0.0);
2806 }
2807
2808 #[cfg(feature = "transform-cast")]
2809 #[test]
2810 fn cast_null_to_float_errors() {
2811 let record = json!({"n": null});
2812 let err = super::apply_all(
2813 record,
2814 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2815 )
2816 .expect_err("null cannot become float");
2817 assert!(
2818 format!("{err}").contains("null cannot be cast to float"),
2819 "{err}"
2820 );
2821 }
2822
2823 #[cfg(feature = "transform-cast")]
2824 #[test]
2825 fn cast_composite_to_float_errors() {
2826 let record = json!({"n": {"x": 1}});
2827 let err = super::apply_all(
2828 record,
2829 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2830 )
2831 .expect_err("object cannot become float");
2832 assert!(format!("{err}").contains("composite"), "{err}");
2833 }
2834
2835 #[cfg(feature = "transform-cast")]
2836 #[test]
2837 fn cast_string_to_float_invalid_errors() {
2838 let record = json!({"n": "not a float"});
2839 let err = super::apply_all(
2840 record,
2841 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2842 )
2843 .expect_err("non-numeric string cannot become float");
2844 assert!(format!("{err}").contains("is not a float"), "{err}");
2845 }
2846
2847 #[cfg(feature = "transform-cast")]
2848 #[test]
2849 fn cast_bool_to_bool_is_identity() {
2850 let record = json!({"b": true});
2851 let result = apply_all(
2852 record,
2853 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
2854 );
2855 assert_eq!(result["b"], true);
2856 }
2857
2858 #[cfg(feature = "transform-cast")]
2859 #[test]
2860 fn cast_number_to_bool() {
2861 let record = json!({"on": 1, "off": 0});
2862 let mut fields = HashMap::new();
2863 fields.insert("on".to_owned(), CastType::Bool);
2864 fields.insert("off".to_owned(), CastType::Bool);
2865 let result = apply_all(
2866 record,
2867 &compiled(&[RecordTransform::Cast {
2868 fields,
2869 on_error: CastOnError::Error,
2870 }]),
2871 );
2872 assert_eq!(result["on"], true);
2873 assert_eq!(result["off"], false);
2874 }
2875
2876 #[cfg(feature = "transform-cast")]
2877 #[test]
2878 fn cast_integer_other_than_zero_one_to_bool_errors() {
2879 let record = json!({"n": 7});
2880 let err = super::apply_all(
2881 record,
2882 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
2883 )
2884 .expect_err("only 0/1 convert to bool");
2885 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
2886 }
2887
2888 #[cfg(feature = "transform-cast")]
2889 #[test]
2890 fn cast_float_number_to_bool_errors() {
2891 let record = json!({"n": 1.5});
2893 let err = super::apply_all(
2894 record,
2895 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
2896 )
2897 .expect_err("fractional number cannot become bool");
2898 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
2899 }
2900
2901 #[cfg(feature = "transform-cast")]
2902 #[test]
2903 fn cast_unrecognised_string_to_bool_errors() {
2904 let record = json!({"flag": "maybe"});
2905 let err = super::apply_all(
2906 record,
2907 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2908 )
2909 .expect_err("'maybe' is not a boolean");
2910 assert!(
2911 format!("{err}").contains("not a recognised boolean"),
2912 "{err}"
2913 );
2914 }
2915
2916 #[cfg(feature = "transform-cast")]
2917 #[test]
2918 fn cast_null_to_bool_errors() {
2919 let record = json!({"b": null});
2920 let err = super::apply_all(
2921 record,
2922 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
2923 )
2924 .expect_err("null cannot become bool");
2925 assert!(
2926 format!("{err}").contains("null cannot be cast to bool"),
2927 "{err}"
2928 );
2929 }
2930
2931 #[cfg(feature = "transform-cast")]
2932 #[test]
2933 fn cast_composite_to_bool_errors() {
2934 let record = json!({"b": [true]});
2935 let err = super::apply_all(
2936 record,
2937 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
2938 )
2939 .expect_err("array cannot become bool");
2940 assert!(format!("{err}").contains("composite"), "{err}");
2941 }
2942
2943 #[cfg(feature = "transform-cast")]
2944 #[test]
2945 fn cast_string_to_string_is_identity() {
2946 let record = json!({"s": "hello"});
2947 let result = apply_all(
2948 record,
2949 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
2950 );
2951 assert_eq!(result["s"], "hello");
2952 }
2953
2954 #[cfg(feature = "transform-cast")]
2955 #[test]
2956 fn cast_bool_to_string() {
2957 let record = json!({"b": true});
2958 let result = apply_all(
2959 record,
2960 &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
2961 );
2962 assert_eq!(result["b"], "true");
2963 }
2964
2965 #[cfg(feature = "transform-cast")]
2966 #[test]
2967 fn cast_null_to_string_errors() {
2968 let record = json!({"s": null});
2969 let err = super::apply_all(
2970 record,
2971 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
2972 )
2973 .expect_err("null cannot become string");
2974 assert!(
2975 format!("{err}").contains("null cannot be cast to string"),
2976 "{err}"
2977 );
2978 }
2979
2980 #[cfg(feature = "transform-cast")]
2981 #[test]
2982 fn cast_composite_to_string_errors() {
2983 let record = json!({"s": {"a": 1}});
2984 let err = super::apply_all(
2985 record,
2986 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
2987 )
2988 .expect_err("object cannot become string");
2989 assert!(format!("{err}").contains("composite"), "{err}");
2990 }
2991
2992 #[cfg(feature = "transform-cast")]
2993 #[test]
2994 fn cast_invalid_timestamp_string_errors() {
2995 let record = json!({"ts": "not a date"});
2996 let err = super::apply_all(
2997 record,
2998 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
2999 )
3000 .expect_err("invalid timestamp string");
3001 assert!(format!("{err}").contains("RFC 3339"), "{err}");
3002 }
3003
3004 #[cfg(feature = "transform-cast")]
3005 #[test]
3006 fn cast_non_string_to_timestamp_names_the_type() {
3007 for (val, ty_name) in [
3009 (json!(null), "null"),
3010 (json!(true), "bool"),
3011 (json!(42), "number"),
3012 (json!([1, 2]), "array"),
3013 (json!({"a": 1}), "object"),
3014 ] {
3015 let record = json!({ "ts": val });
3016 let err = super::apply_all(
3017 record,
3018 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3019 )
3020 .expect_err("non-string cannot become timestamp");
3021 let msg = format!("{err}");
3022 assert!(msg.contains("timestamp"), "{msg}");
3023 assert!(
3024 msg.contains(ty_name),
3025 "expected type name {ty_name:?} in: {msg}"
3026 );
3027 }
3028 }
3029}