1use crate::error::FaucetError;
47#[cfg(any(
48 feature = "transform-flatten",
49 feature = "transform-rename-keys",
50 feature = "transform-keys-case",
51 feature = "transform-set",
52))]
53use serde_json::Map;
54use serde_json::Value;
55use std::fmt;
56use std::sync::Arc;
57
58#[cfg(any(
59 feature = "transform-cast",
60 feature = "transform-rename-field",
61 feature = "transform-value-case",
62 feature = "transform-spell-symbols",
63))]
64use std::collections::HashMap;
65
66#[cfg(feature = "transform-rename-keys")]
67use regex::Regex;
68
69#[cfg(feature = "transform-cast")]
78#[derive(
79 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
80)]
81#[serde(rename_all = "lowercase")]
82pub enum CastType {
83 Int,
85 Float,
87 Bool,
90 String,
92 Timestamp,
94}
95
96#[cfg(feature = "transform-cast")]
98#[derive(
99 Debug,
100 Clone,
101 Copy,
102 PartialEq,
103 Eq,
104 serde::Deserialize,
105 serde::Serialize,
106 schemars::JsonSchema,
107 Default,
108)]
109#[serde(rename_all = "lowercase")]
110pub enum CastOnError {
111 #[default]
113 Error,
114 Null,
116 Skip,
118}
119
120#[cfg(feature = "transform-keys-case")]
126#[derive(
127 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
128)]
129#[serde(rename_all = "snake_case")]
130#[non_exhaustive]
133pub enum KeyCaseMode {
134 Snake,
136 Camel,
139 Pascal,
141 Kebab,
143 ScreamingSnake,
145 Dot,
148}
149
150#[cfg(feature = "transform-value-case")]
152#[derive(
153 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
154)]
155#[serde(rename_all = "lowercase")]
156#[non_exhaustive]
159pub enum ValueCaseMode {
160 Lower,
162 Upper,
164 Trim,
166 Title,
169 Capitalize,
172}
173
174#[cfg(feature = "transform-hash")]
176#[derive(
177 Debug,
178 Clone,
179 Copy,
180 PartialEq,
181 Eq,
182 serde::Deserialize,
183 serde::Serialize,
184 schemars::JsonSchema,
185 Default,
186)]
187#[serde(rename_all = "lowercase")]
188pub enum HashAlgorithm {
189 #[default]
191 Sha256,
192 Blake3,
194}
195
196#[cfg(feature = "transform-hash")]
198#[derive(
199 Debug,
200 Clone,
201 Copy,
202 PartialEq,
203 Eq,
204 serde::Deserialize,
205 serde::Serialize,
206 schemars::JsonSchema,
207 Default,
208)]
209#[serde(rename_all = "lowercase")]
210pub enum HashEncoding {
211 #[default]
213 Hex,
214 Base64,
216}
217
218#[cfg(feature = "transform-json-parse")]
224#[derive(
225 Debug,
226 Clone,
227 Copy,
228 PartialEq,
229 Eq,
230 serde::Deserialize,
231 serde::Serialize,
232 schemars::JsonSchema,
233 Default,
234)]
235#[serde(rename_all = "snake_case")]
236pub enum JsonParseOnError {
237 #[default]
239 Keep,
240 Null,
242 Error,
244}
245
246#[non_exhaustive]
262pub enum RecordTransform {
263 #[cfg(feature = "transform-flatten")]
276 Flatten { separator: String },
277
278 #[cfg(feature = "transform-rename-keys")]
293 RenameKeys {
294 pattern: String,
295 replacement: String,
296 },
297
298 #[cfg(feature = "transform-keys-case")]
313 KeysCase { mode: KeyCaseMode },
314
315 #[cfg(feature = "transform-select")]
322 Select { fields: Vec<String> },
323
324 #[cfg(feature = "transform-drop")]
330 Drop { fields: Vec<String> },
331
332 #[cfg(feature = "transform-set")]
339 Set { values: Map<String, Value> },
340
341 #[cfg(feature = "transform-rename-field")]
350 RenameField {
351 fields: HashMap<String, String>,
353 },
354
355 #[cfg(feature = "transform-cast")]
363 Cast {
364 fields: HashMap<String, CastType>,
365 on_error: CastOnError,
366 },
367
368 #[cfg(feature = "transform-redact")]
375 Redact { fields: Vec<String>, mask: Value },
376
377 #[cfg(feature = "transform-value-case")]
384 ValueCase {
385 fields: Vec<String>,
386 mode: ValueCaseMode,
387 },
388
389 #[cfg(feature = "transform-spell-symbols")]
410 SpellSymbols {
411 extra: HashMap<String, String>,
414 separator: String,
417 },
418
419 #[cfg(feature = "transform-hash")]
433 Hash {
434 fields: Vec<String>,
435 algorithm: HashAlgorithm,
436 encoding: HashEncoding,
437 salt: Option<String>,
438 into: Option<String>,
439 },
440
441 #[cfg(feature = "transform-json-parse")]
452 JsonParse {
453 fields: Vec<String>,
454 on_error: JsonParseOnError,
455 into: Option<String>,
456 },
457
458 #[cfg(feature = "transform-coalesce")]
468 Coalesce {
469 field: String,
470 default: Option<Value>,
472 from: Vec<String>,
475 treat_empty_string_as_null: bool,
477 },
478
479 #[cfg(feature = "transform-split-join")]
487 Split {
488 field: String,
489 delimiter: String,
490 trim: bool,
491 into: Option<String>,
492 },
493
494 #[cfg(feature = "transform-split-join")]
503 Join {
504 field: String,
505 delimiter: String,
506 into: Option<String>,
507 },
508
509 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
516}
517
518impl fmt::Debug for RecordTransform {
519 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
520 match self {
521 #[cfg(feature = "transform-flatten")]
522 Self::Flatten { separator } => f
523 .debug_struct("Flatten")
524 .field("separator", separator)
525 .finish(),
526 #[cfg(feature = "transform-rename-keys")]
527 Self::RenameKeys {
528 pattern,
529 replacement,
530 } => f
531 .debug_struct("RenameKeys")
532 .field("pattern", pattern)
533 .field("replacement", replacement)
534 .finish(),
535 #[cfg(feature = "transform-keys-case")]
536 Self::KeysCase { mode } => f.debug_struct("KeysCase").field("mode", mode).finish(),
537 #[cfg(feature = "transform-select")]
538 Self::Select { fields } => f.debug_struct("Select").field("fields", fields).finish(),
539 #[cfg(feature = "transform-drop")]
540 Self::Drop { fields } => f.debug_struct("Drop").field("fields", fields).finish(),
541 #[cfg(feature = "transform-set")]
542 Self::Set { values } => f.debug_struct("Set").field("values", values).finish(),
543 #[cfg(feature = "transform-rename-field")]
544 Self::RenameField { fields } => f
545 .debug_struct("RenameField")
546 .field("fields", fields)
547 .finish(),
548 #[cfg(feature = "transform-cast")]
549 Self::Cast { fields, on_error } => f
550 .debug_struct("Cast")
551 .field("fields", fields)
552 .field("on_error", on_error)
553 .finish(),
554 #[cfg(feature = "transform-redact")]
555 Self::Redact { fields, mask } => f
556 .debug_struct("Redact")
557 .field("fields", fields)
558 .field("mask", mask)
559 .finish(),
560 #[cfg(feature = "transform-value-case")]
561 Self::ValueCase { fields, mode } => f
562 .debug_struct("ValueCase")
563 .field("fields", fields)
564 .field("mode", mode)
565 .finish(),
566 #[cfg(feature = "transform-spell-symbols")]
567 Self::SpellSymbols { extra, separator } => f
568 .debug_struct("SpellSymbols")
569 .field("extra", extra)
570 .field("separator", separator)
571 .finish(),
572 #[cfg(feature = "transform-hash")]
573 Self::Hash {
574 fields,
575 algorithm,
576 encoding,
577 salt,
578 into,
579 } => f
580 .debug_struct("Hash")
581 .field("fields", fields)
582 .field("algorithm", algorithm)
583 .field("encoding", encoding)
584 .field("salt", &salt.as_ref().map(|_| "<redacted>"))
586 .field("into", into)
587 .finish(),
588 #[cfg(feature = "transform-json-parse")]
589 Self::JsonParse {
590 fields,
591 on_error,
592 into,
593 } => f
594 .debug_struct("JsonParse")
595 .field("fields", fields)
596 .field("on_error", on_error)
597 .field("into", into)
598 .finish(),
599 #[cfg(feature = "transform-coalesce")]
600 Self::Coalesce {
601 field,
602 default,
603 from,
604 treat_empty_string_as_null,
605 } => f
606 .debug_struct("Coalesce")
607 .field("field", field)
608 .field("default", default)
609 .field("from", from)
610 .field("treat_empty_string_as_null", treat_empty_string_as_null)
611 .finish(),
612 #[cfg(feature = "transform-split-join")]
613 Self::Split {
614 field,
615 delimiter,
616 trim,
617 into,
618 } => f
619 .debug_struct("Split")
620 .field("field", field)
621 .field("delimiter", delimiter)
622 .field("trim", trim)
623 .field("into", into)
624 .finish(),
625 #[cfg(feature = "transform-split-join")]
626 Self::Join {
627 field,
628 delimiter,
629 into,
630 } => f
631 .debug_struct("Join")
632 .field("field", field)
633 .field("delimiter", delimiter)
634 .field("into", into)
635 .finish(),
636 Self::Custom(_) => write!(f, "Custom(<fn>)"),
637 }
638 }
639}
640
641impl Clone for RecordTransform {
644 fn clone(&self) -> Self {
645 match self {
646 #[cfg(feature = "transform-flatten")]
647 Self::Flatten { separator } => Self::Flatten {
648 separator: separator.clone(),
649 },
650 #[cfg(feature = "transform-rename-keys")]
651 Self::RenameKeys {
652 pattern,
653 replacement,
654 } => Self::RenameKeys {
655 pattern: pattern.clone(),
656 replacement: replacement.clone(),
657 },
658 #[cfg(feature = "transform-keys-case")]
659 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
660 #[cfg(feature = "transform-select")]
661 Self::Select { fields } => Self::Select {
662 fields: fields.clone(),
663 },
664 #[cfg(feature = "transform-drop")]
665 Self::Drop { fields } => Self::Drop {
666 fields: fields.clone(),
667 },
668 #[cfg(feature = "transform-set")]
669 Self::Set { values } => Self::Set {
670 values: values.clone(),
671 },
672 #[cfg(feature = "transform-rename-field")]
673 Self::RenameField { fields } => Self::RenameField {
674 fields: fields.clone(),
675 },
676 #[cfg(feature = "transform-cast")]
677 Self::Cast { fields, on_error } => Self::Cast {
678 fields: fields.clone(),
679 on_error: *on_error,
680 },
681 #[cfg(feature = "transform-redact")]
682 Self::Redact { fields, mask } => Self::Redact {
683 fields: fields.clone(),
684 mask: mask.clone(),
685 },
686 #[cfg(feature = "transform-value-case")]
687 Self::ValueCase { fields, mode } => Self::ValueCase {
688 fields: fields.clone(),
689 mode: *mode,
690 },
691 #[cfg(feature = "transform-spell-symbols")]
692 Self::SpellSymbols { extra, separator } => Self::SpellSymbols {
693 extra: extra.clone(),
694 separator: separator.clone(),
695 },
696 #[cfg(feature = "transform-hash")]
697 Self::Hash {
698 fields,
699 algorithm,
700 encoding,
701 salt,
702 into,
703 } => Self::Hash {
704 fields: fields.clone(),
705 algorithm: *algorithm,
706 encoding: *encoding,
707 salt: salt.clone(),
708 into: into.clone(),
709 },
710 #[cfg(feature = "transform-json-parse")]
711 Self::JsonParse {
712 fields,
713 on_error,
714 into,
715 } => Self::JsonParse {
716 fields: fields.clone(),
717 on_error: *on_error,
718 into: into.clone(),
719 },
720 #[cfg(feature = "transform-coalesce")]
721 Self::Coalesce {
722 field,
723 default,
724 from,
725 treat_empty_string_as_null,
726 } => Self::Coalesce {
727 field: field.clone(),
728 default: default.clone(),
729 from: from.clone(),
730 treat_empty_string_as_null: *treat_empty_string_as_null,
731 },
732 #[cfg(feature = "transform-split-join")]
733 Self::Split {
734 field,
735 delimiter,
736 trim,
737 into,
738 } => Self::Split {
739 field: field.clone(),
740 delimiter: delimiter.clone(),
741 trim: *trim,
742 into: into.clone(),
743 },
744 #[cfg(feature = "transform-split-join")]
745 Self::Join {
746 field,
747 delimiter,
748 into,
749 } => Self::Join {
750 field: field.clone(),
751 delimiter: delimiter.clone(),
752 into: into.clone(),
753 },
754 Self::Custom(f) => Self::Custom(Arc::clone(f)),
755 }
756 }
757}
758
759impl Clone for CompiledTransform {
762 fn clone(&self) -> Self {
763 match self {
764 #[cfg(feature = "transform-flatten")]
765 Self::Flatten { separator } => Self::Flatten {
766 separator: separator.clone(),
767 },
768 #[cfg(feature = "transform-rename-keys")]
769 Self::RenameKeys { re, replacement } => Self::RenameKeys {
770 re: re.clone(),
771 replacement: replacement.clone(),
772 },
773 #[cfg(feature = "transform-keys-case")]
774 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
775 #[cfg(feature = "transform-select")]
776 Self::Select { fields } => Self::Select {
777 fields: fields.clone(),
778 },
779 #[cfg(feature = "transform-drop")]
780 Self::Drop { fields } => Self::Drop {
781 fields: fields.clone(),
782 },
783 #[cfg(feature = "transform-set")]
784 Self::Set { values } => Self::Set {
785 values: values.clone(),
786 },
787 #[cfg(feature = "transform-rename-field")]
788 Self::RenameField { fields } => Self::RenameField {
789 fields: fields.clone(),
790 },
791 #[cfg(feature = "transform-cast")]
792 Self::Cast { fields, on_error } => Self::Cast {
793 fields: fields.clone(),
794 on_error: *on_error,
795 },
796 #[cfg(feature = "transform-redact")]
797 Self::Redact { fields, mask } => Self::Redact {
798 fields: fields.clone(),
799 mask: mask.clone(),
800 },
801 #[cfg(feature = "transform-value-case")]
802 Self::ValueCase { fields, mode } => Self::ValueCase {
803 fields: fields.clone(),
804 mode: *mode,
805 },
806 #[cfg(feature = "transform-spell-symbols")]
807 Self::SpellSymbols {
808 replacements,
809 separator,
810 } => Self::SpellSymbols {
811 replacements: replacements.clone(),
812 separator: separator.clone(),
813 },
814 #[cfg(feature = "transform-hash")]
815 Self::Hash {
816 fields,
817 algorithm,
818 encoding,
819 salt,
820 into,
821 } => Self::Hash {
822 fields: fields.clone(),
823 algorithm: *algorithm,
824 encoding: *encoding,
825 salt: salt.clone(),
826 into: into.clone(),
827 },
828 #[cfg(feature = "transform-json-parse")]
829 Self::JsonParse {
830 fields,
831 on_error,
832 into,
833 } => Self::JsonParse {
834 fields: fields.clone(),
835 on_error: *on_error,
836 into: into.clone(),
837 },
838 #[cfg(feature = "transform-coalesce")]
839 Self::Coalesce {
840 field,
841 default,
842 from,
843 treat_empty_string_as_null,
844 } => Self::Coalesce {
845 field: field.clone(),
846 default: default.clone(),
847 from: from.clone(),
848 treat_empty_string_as_null: *treat_empty_string_as_null,
849 },
850 #[cfg(feature = "transform-split-join")]
851 Self::Split {
852 field,
853 delimiter,
854 trim,
855 into,
856 } => Self::Split {
857 field: field.clone(),
858 delimiter: delimiter.clone(),
859 trim: *trim,
860 into: into.clone(),
861 },
862 #[cfg(feature = "transform-split-join")]
863 Self::Join {
864 field,
865 delimiter,
866 into,
867 } => Self::Join {
868 field: field.clone(),
869 delimiter: delimiter.clone(),
870 into: into.clone(),
871 },
872 Self::Custom(f) => Self::Custom(Arc::clone(f)),
873 }
874 }
875}
876
877impl RecordTransform {
878 pub fn custom<F>(f: F) -> Self
902 where
903 F: Fn(Value) -> Value + Send + Sync + 'static,
904 {
905 Self::Custom(Arc::new(f))
906 }
907}
908
909#[non_exhaustive]
920pub enum CompiledTransform {
921 #[cfg(feature = "transform-flatten")]
922 Flatten {
923 separator: String,
924 },
925 #[cfg(feature = "transform-rename-keys")]
926 RenameKeys {
927 re: Regex,
928 replacement: String,
929 },
930 #[cfg(feature = "transform-keys-case")]
931 KeysCase {
932 mode: KeyCaseMode,
933 },
934 #[cfg(feature = "transform-select")]
935 Select {
936 fields: Vec<String>,
937 },
938 #[cfg(feature = "transform-drop")]
939 Drop {
940 fields: Vec<String>,
941 },
942 #[cfg(feature = "transform-set")]
943 Set {
944 values: Map<String, Value>,
945 },
946 #[cfg(feature = "transform-rename-field")]
947 RenameField {
948 fields: Vec<(String, String)>,
951 },
952 #[cfg(feature = "transform-cast")]
953 Cast {
954 fields: HashMap<String, CastType>,
955 on_error: CastOnError,
956 },
957 #[cfg(feature = "transform-redact")]
958 Redact {
959 fields: Vec<String>,
960 mask: Value,
961 },
962 #[cfg(feature = "transform-value-case")]
963 ValueCase {
964 fields: Vec<String>,
965 mode: ValueCaseMode,
966 },
967 #[cfg(feature = "transform-spell-symbols")]
968 SpellSymbols {
969 replacements: Vec<(String, String)>,
972 separator: String,
973 },
974 #[cfg(feature = "transform-hash")]
975 Hash {
976 fields: Vec<String>,
977 algorithm: HashAlgorithm,
978 encoding: HashEncoding,
979 salt: Option<String>,
980 into: Option<String>,
981 },
982 #[cfg(feature = "transform-json-parse")]
983 JsonParse {
984 fields: Vec<String>,
985 on_error: JsonParseOnError,
986 into: Option<String>,
987 },
988 #[cfg(feature = "transform-coalesce")]
989 Coalesce {
990 field: String,
991 default: Option<Value>,
992 from: Vec<String>,
993 treat_empty_string_as_null: bool,
994 },
995 #[cfg(feature = "transform-split-join")]
996 Split {
997 field: String,
998 delimiter: String,
999 trim: bool,
1000 into: Option<String>,
1001 },
1002 #[cfg(feature = "transform-split-join")]
1003 Join {
1004 field: String,
1005 delimiter: String,
1006 into: Option<String>,
1007 },
1008 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
1009}
1010
1011pub fn compile(t: &RecordTransform) -> Result<CompiledTransform, FaucetError> {
1015 match t {
1016 #[cfg(feature = "transform-flatten")]
1017 RecordTransform::Flatten { separator } => Ok(CompiledTransform::Flatten {
1018 separator: separator.clone(),
1019 }),
1020 #[cfg(feature = "transform-rename-keys")]
1021 RecordTransform::RenameKeys {
1022 pattern,
1023 replacement,
1024 } => {
1025 let re = Regex::new(pattern)
1026 .map_err(|e| FaucetError::Transform(format!("invalid regex '{pattern}': {e}")))?;
1027 Ok(CompiledTransform::RenameKeys {
1028 re,
1029 replacement: replacement.clone(),
1030 })
1031 }
1032 #[cfg(feature = "transform-keys-case")]
1033 RecordTransform::KeysCase { mode } => Ok(CompiledTransform::KeysCase { mode: *mode }),
1034 #[cfg(feature = "transform-select")]
1035 RecordTransform::Select { fields } => Ok(CompiledTransform::Select {
1036 fields: fields.clone(),
1037 }),
1038 #[cfg(feature = "transform-drop")]
1039 RecordTransform::Drop { fields } => Ok(CompiledTransform::Drop {
1040 fields: fields.clone(),
1041 }),
1042 #[cfg(feature = "transform-set")]
1043 RecordTransform::Set { values } => Ok(CompiledTransform::Set {
1044 values: values.clone(),
1045 }),
1046 #[cfg(feature = "transform-rename-field")]
1047 RecordTransform::RenameField { fields } => {
1048 let mut fields: Vec<(String, String)> =
1053 fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
1054 fields.sort();
1055 Ok(CompiledTransform::RenameField { fields })
1056 }
1057 #[cfg(feature = "transform-cast")]
1058 RecordTransform::Cast { fields, on_error } => Ok(CompiledTransform::Cast {
1059 fields: fields.clone(),
1060 on_error: *on_error,
1061 }),
1062 #[cfg(feature = "transform-redact")]
1063 RecordTransform::Redact { fields, mask } => Ok(CompiledTransform::Redact {
1064 fields: fields.clone(),
1065 mask: mask.clone(),
1066 }),
1067 #[cfg(feature = "transform-value-case")]
1068 RecordTransform::ValueCase { fields, mode } => Ok(CompiledTransform::ValueCase {
1069 fields: fields.clone(),
1070 mode: *mode,
1071 }),
1072 #[cfg(feature = "transform-spell-symbols")]
1073 RecordTransform::SpellSymbols { extra, separator } => {
1074 let mut merged = default_symbol_map();
1077 for (k, v) in extra {
1078 merged.insert(k.clone(), v.clone());
1079 }
1080 let mut replacements: Vec<(String, String)> = merged.into_iter().collect();
1081 replacements.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
1082 Ok(CompiledTransform::SpellSymbols {
1083 replacements,
1084 separator: separator.clone(),
1085 })
1086 }
1087 #[cfg(feature = "transform-hash")]
1088 RecordTransform::Hash {
1089 fields,
1090 algorithm,
1091 encoding,
1092 salt,
1093 into,
1094 } => {
1095 if fields.is_empty() {
1096 return Err(FaucetError::Config(
1097 "hash: `fields` must not be empty".to_owned(),
1098 ));
1099 }
1100 if into.is_some() && fields.len() != 1 {
1101 return Err(FaucetError::Config(
1102 "hash: `into` is only valid with exactly one field".to_owned(),
1103 ));
1104 }
1105 Ok(CompiledTransform::Hash {
1106 fields: fields.clone(),
1107 algorithm: *algorithm,
1108 encoding: *encoding,
1109 salt: salt.clone(),
1110 into: into.clone(),
1111 })
1112 }
1113 #[cfg(feature = "transform-json-parse")]
1114 RecordTransform::JsonParse {
1115 fields,
1116 on_error,
1117 into,
1118 } => {
1119 if fields.is_empty() {
1120 return Err(FaucetError::Config(
1121 "json_parse: `fields` must not be empty".to_owned(),
1122 ));
1123 }
1124 if into.is_some() && fields.len() != 1 {
1125 return Err(FaucetError::Config(
1126 "json_parse: `into` is only valid with exactly one field".to_owned(),
1127 ));
1128 }
1129 Ok(CompiledTransform::JsonParse {
1130 fields: fields.clone(),
1131 on_error: *on_error,
1132 into: into.clone(),
1133 })
1134 }
1135 #[cfg(feature = "transform-coalesce")]
1136 RecordTransform::Coalesce {
1137 field,
1138 default,
1139 from,
1140 treat_empty_string_as_null,
1141 } => {
1142 match (default.is_some(), from.is_empty()) {
1143 (true, true) => {}
1145 (false, false) => {}
1147 (true, false) => {
1148 return Err(FaucetError::Config(
1149 "coalesce: set exactly one of `default` or `from`, not both".to_owned(),
1150 ));
1151 }
1152 (false, true) => {
1153 return Err(FaucetError::Config(
1154 "coalesce: set exactly one of `default` or `from`".to_owned(),
1155 ));
1156 }
1157 }
1158 Ok(CompiledTransform::Coalesce {
1159 field: field.clone(),
1160 default: default.clone(),
1161 from: from.clone(),
1162 treat_empty_string_as_null: *treat_empty_string_as_null,
1163 })
1164 }
1165 #[cfg(feature = "transform-split-join")]
1166 RecordTransform::Split {
1167 field,
1168 delimiter,
1169 trim,
1170 into,
1171 } => Ok(CompiledTransform::Split {
1172 field: field.clone(),
1173 delimiter: delimiter.clone(),
1174 trim: *trim,
1175 into: into.clone(),
1176 }),
1177 #[cfg(feature = "transform-split-join")]
1178 RecordTransform::Join {
1179 field,
1180 delimiter,
1181 into,
1182 } => Ok(CompiledTransform::Join {
1183 field: field.clone(),
1184 delimiter: delimiter.clone(),
1185 into: into.clone(),
1186 }),
1187 RecordTransform::Custom(f) => Ok(CompiledTransform::Custom(Arc::clone(f))),
1188 }
1189}
1190
1191pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
1197 let mut acc = record;
1198 for t in transforms {
1199 acc = apply_one(acc, t)?;
1200 }
1201 Ok(acc)
1202}
1203
1204fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
1205 match t {
1206 #[cfg(feature = "transform-flatten")]
1207 CompiledTransform::Flatten { separator } => flatten(value, separator),
1208 #[cfg(feature = "transform-rename-keys")]
1209 CompiledTransform::RenameKeys { re, replacement } => {
1210 Ok(rename_keys(value, re, replacement))
1211 }
1212 #[cfg(feature = "transform-keys-case")]
1213 CompiledTransform::KeysCase { mode } => keys_case(value, *mode),
1214 #[cfg(feature = "transform-select")]
1215 CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
1216 #[cfg(feature = "transform-drop")]
1217 CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
1218 #[cfg(feature = "transform-set")]
1219 CompiledTransform::Set { values } => Ok(set_fields(value, values)),
1220 #[cfg(feature = "transform-rename-field")]
1221 CompiledTransform::RenameField { fields } => rename_field(value, fields),
1222 #[cfg(feature = "transform-cast")]
1223 CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
1224 #[cfg(feature = "transform-redact")]
1225 CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
1226 #[cfg(feature = "transform-value-case")]
1227 CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
1228 #[cfg(feature = "transform-spell-symbols")]
1229 CompiledTransform::SpellSymbols {
1230 replacements,
1231 separator,
1232 } => spell_symbols(value, replacements, separator),
1233 #[cfg(feature = "transform-hash")]
1234 CompiledTransform::Hash {
1235 fields,
1236 algorithm,
1237 encoding,
1238 salt,
1239 into,
1240 } => Ok(hash_fields(
1241 value,
1242 fields,
1243 *algorithm,
1244 *encoding,
1245 salt.as_deref(),
1246 into.as_deref(),
1247 )),
1248 #[cfg(feature = "transform-json-parse")]
1249 CompiledTransform::JsonParse {
1250 fields,
1251 on_error,
1252 into,
1253 } => json_parse_fields(value, fields, *on_error, into.as_deref()),
1254 #[cfg(feature = "transform-coalesce")]
1255 CompiledTransform::Coalesce {
1256 field,
1257 default,
1258 from,
1259 treat_empty_string_as_null,
1260 } => Ok(coalesce_field(
1261 value,
1262 field,
1263 default.as_ref(),
1264 from,
1265 *treat_empty_string_as_null,
1266 )),
1267 #[cfg(feature = "transform-split-join")]
1268 CompiledTransform::Split {
1269 field,
1270 delimiter,
1271 trim,
1272 into,
1273 } => Ok(split_field(value, field, delimiter, *trim, into.as_deref())),
1274 #[cfg(feature = "transform-split-join")]
1275 CompiledTransform::Join {
1276 field,
1277 delimiter,
1278 into,
1279 } => Ok(join_field(value, field, delimiter, into.as_deref())),
1280 CompiledTransform::Custom(f) => Ok(f(value)),
1281 }
1282}
1283
1284#[cfg(feature = "transform-flatten")]
1287fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
1288 match value {
1289 Value::Object(_) => {
1290 let mut out = Map::new();
1291 flatten_into(value, "", separator, &mut out)?;
1292 Ok(Value::Object(out))
1293 }
1294 other => Ok(other),
1295 }
1296}
1297
1298#[cfg(feature = "transform-flatten")]
1299fn flatten_into(
1300 value: Value,
1301 prefix: &str,
1302 separator: &str,
1303 out: &mut Map<String, Value>,
1304) -> Result<(), FaucetError> {
1305 match value {
1306 Value::Object(map) => {
1307 for (k, v) in map {
1308 let key = if prefix.is_empty() {
1309 k
1310 } else {
1311 format!("{prefix}{separator}{k}")
1312 };
1313 flatten_into(v, &key, separator, out)?;
1314 }
1315 }
1316 other => {
1317 if out.contains_key(prefix) {
1321 return Err(FaucetError::Transform(format!(
1322 "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
1323 to the same flattened key (separator '{separator}')"
1324 )));
1325 }
1326 out.insert(prefix.to_string(), other);
1327 }
1328 }
1329 Ok(())
1330}
1331
1332#[cfg(feature = "transform-rename-keys")]
1335fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Value {
1336 match value {
1337 Value::Object(map) => {
1338 let new_map: Map<String, Value> = map
1339 .into_iter()
1340 .map(|(k, v)| {
1341 let new_k = re.replace_all(&k, replacement).into_owned();
1342 (new_k, rename_keys(v, re, replacement))
1343 })
1344 .collect();
1345 Value::Object(new_map)
1346 }
1347 Value::Array(arr) => Value::Array(
1348 arr.into_iter()
1349 .map(|v| rename_keys(v, re, replacement))
1350 .collect(),
1351 ),
1352 other => other,
1353 }
1354}
1355
1356#[cfg(feature = "transform-keys-case")]
1360fn keys_case(value: Value, mode: KeyCaseMode) -> Result<Value, FaucetError> {
1361 match value {
1362 Value::Object(map) => {
1363 let mut new_map = Map::with_capacity(map.len());
1364 for (k, v) in map {
1365 let tokens = tokenize_key(&k);
1366 let recased = if tokens.is_empty() {
1367 k
1370 } else {
1371 apply_key_case(tokens, mode)
1372 };
1373 let new_v = keys_case(v, mode)?;
1374 if new_map.contains_key(&recased) {
1375 return Err(FaucetError::Transform(format!(
1376 "keys_case produced a duplicate key '{recased}'; two distinct keys \
1377 re-case to the same name under mode {mode:?}"
1378 )));
1379 }
1380 new_map.insert(recased, new_v);
1381 }
1382 Ok(Value::Object(new_map))
1383 }
1384 Value::Array(arr) => {
1385 let mut out = Vec::with_capacity(arr.len());
1386 for v in arr {
1387 out.push(keys_case(v, mode)?);
1388 }
1389 Ok(Value::Array(out))
1390 }
1391 other => Ok(other),
1392 }
1393}
1394
1395#[cfg(feature = "transform-keys-case")]
1401fn tokenize_key(key: &str) -> Vec<String> {
1402 let mut tokens: Vec<String> = Vec::new();
1403 let mut current = String::new();
1404 let mut prev_was_lower = false;
1405 for ch in key.chars() {
1406 if ch.is_alphanumeric() {
1407 if prev_was_lower && ch.is_uppercase() && !current.is_empty() {
1408 tokens.push(std::mem::take(&mut current));
1409 }
1410 current.push(ch);
1411 prev_was_lower = ch.is_lowercase();
1412 } else {
1413 if !current.is_empty() {
1414 tokens.push(std::mem::take(&mut current));
1415 }
1416 prev_was_lower = false;
1417 }
1418 }
1419 if !current.is_empty() {
1420 tokens.push(current);
1421 }
1422 tokens
1423}
1424
1425#[cfg(feature = "transform-keys-case")]
1426fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
1427 match mode {
1428 KeyCaseMode::Snake => tokens
1429 .iter()
1430 .map(|t| t.to_lowercase())
1431 .collect::<Vec<_>>()
1432 .join("_"),
1433 KeyCaseMode::ScreamingSnake => tokens
1434 .iter()
1435 .map(|t| t.to_uppercase())
1436 .collect::<Vec<_>>()
1437 .join("_"),
1438 KeyCaseMode::Kebab => tokens
1439 .iter()
1440 .map(|t| t.to_lowercase())
1441 .collect::<Vec<_>>()
1442 .join("-"),
1443 KeyCaseMode::Dot => tokens
1444 .iter()
1445 .map(|t| t.to_lowercase())
1446 .collect::<Vec<_>>()
1447 .join("."),
1448 KeyCaseMode::Camel => {
1449 let mut iter = tokens.into_iter();
1450 match iter.next() {
1451 None => String::new(),
1452 Some(first) => {
1453 let mut out = first.to_lowercase();
1454 for t in iter {
1455 out.push_str(&capitalize_token(&t));
1456 }
1457 out
1458 }
1459 }
1460 }
1461 KeyCaseMode::Pascal => tokens
1462 .into_iter()
1463 .map(|t| capitalize_token(&t))
1464 .collect::<String>(),
1465 }
1466}
1467
1468#[cfg(feature = "transform-keys-case")]
1470fn capitalize_token(s: &str) -> String {
1471 let lower = s.to_lowercase();
1472 let mut chars = lower.chars();
1473 match chars.next() {
1474 None => String::new(),
1475 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1476 }
1477}
1478
1479#[cfg(feature = "transform-select")]
1482fn select_fields(value: Value, fields: &[String]) -> Value {
1483 match value {
1484 Value::Object(map) => {
1485 let mut out = Map::with_capacity(fields.len().min(map.len()));
1486 for f in fields {
1488 if let Some(v) = map.get(f) {
1489 out.insert(f.clone(), v.clone());
1490 }
1491 }
1492 Value::Object(out)
1493 }
1494 other => other,
1495 }
1496}
1497
1498#[cfg(feature = "transform-drop")]
1501fn drop_fields(value: Value, fields: &[String]) -> Value {
1502 match value {
1503 Value::Object(mut map) => {
1504 for f in fields {
1505 map.remove(f);
1506 }
1507 Value::Object(map)
1508 }
1509 other => other,
1510 }
1511}
1512
1513#[cfg(feature = "transform-set")]
1516fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
1517 match value {
1518 Value::Object(mut map) => {
1519 for (k, v) in values {
1520 map.insert(k.clone(), v.clone());
1521 }
1522 Value::Object(map)
1523 }
1524 other => other,
1525 }
1526}
1527
1528#[cfg(feature = "transform-rename-field")]
1531fn rename_field(value: Value, fields: &[(String, String)]) -> Result<Value, FaucetError> {
1532 match value {
1533 Value::Object(mut map) => {
1534 let renames: Vec<(&str, &str)> = fields
1540 .iter()
1541 .filter(|(from, to)| from != to && map.contains_key(from))
1542 .map(|(from, to)| (from.as_str(), to.as_str()))
1543 .collect();
1544 let sources: std::collections::HashSet<&str> =
1545 renames.iter().map(|(from, _)| *from).collect();
1546
1547 let mut seen_targets: std::collections::HashSet<&str> =
1549 std::collections::HashSet::new();
1550 for (from, to) in &renames {
1551 if !seen_targets.insert(to) {
1552 return Err(FaucetError::Transform(format!(
1553 "rename_field: two fields rename to the same target key '{to}'"
1554 )));
1555 }
1556 if map.contains_key(*to) && !sources.contains(to) {
1560 return Err(FaucetError::Transform(format!(
1561 "rename_field: target key '{to}' already exists on the record \
1562 (renaming from '{from}')"
1563 )));
1564 }
1565 }
1566
1567 let staged: Vec<(String, Value)> = renames
1568 .iter()
1569 .map(|(from, to)| {
1570 let v = map.remove(*from).expect("source presence checked above");
1571 (to.to_string(), v)
1572 })
1573 .collect();
1574 for (to, v) in staged {
1575 map.insert(to, v);
1576 }
1577 Ok(Value::Object(map))
1578 }
1579 other => Ok(other),
1580 }
1581}
1582
1583#[cfg(feature = "transform-cast")]
1586fn cast_fields(
1587 value: Value,
1588 fields: &HashMap<String, CastType>,
1589 on_error: CastOnError,
1590) -> Result<Value, FaucetError> {
1591 match value {
1592 Value::Object(mut map) => {
1593 for (field, target) in fields {
1594 let Some(current) = map.get(field) else {
1595 continue;
1596 };
1597 match cast_value(current, *target) {
1598 Ok(new_val) => {
1599 map.insert(field.clone(), new_val);
1600 }
1601 Err(msg) => match on_error {
1602 CastOnError::Error => {
1603 return Err(FaucetError::Transform(format!(
1604 "cast: field '{field}' to {target:?} failed: {msg}"
1605 )));
1606 }
1607 CastOnError::Null => {
1608 map.insert(field.clone(), Value::Null);
1609 }
1610 CastOnError::Skip => { }
1611 },
1612 }
1613 }
1614 Ok(Value::Object(map))
1615 }
1616 other => Ok(other),
1617 }
1618}
1619
1620#[cfg(feature = "transform-cast")]
1623fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1624 match target {
1625 CastType::Int => match v {
1626 Value::Number(n) => {
1627 if let Some(i) = n.as_i64() {
1628 return Ok(Value::Number(i.into()));
1629 }
1630 match n.as_f64() {
1637 Some(f)
1638 if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1639 {
1640 Ok(Value::Number((f as i64).into()))
1641 }
1642 Some(f) => Err(format!(
1643 "float '{f}' is not a whole number representable as i64"
1644 )),
1645 None => Err(format!("number '{n}' is not representable as i64")),
1646 }
1647 }
1648 Value::String(s) => s
1649 .trim()
1650 .parse::<i64>()
1651 .map(|i| Value::Number(i.into()))
1652 .map_err(|e| format!("'{s}' is not an integer: {e}")),
1653 Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1654 Value::Null => Err("null cannot be cast to int".to_owned()),
1655 Value::Array(_) | Value::Object(_) => {
1656 Err("composite values cannot be cast to int".to_owned())
1657 }
1658 },
1659 CastType::Float => match v {
1660 Value::Number(n) => n
1661 .as_f64()
1662 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1663 .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1664 Value::String(s) => s
1665 .trim()
1666 .parse::<f64>()
1667 .ok()
1668 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1669 .ok_or_else(|| format!("'{s}' is not a float")),
1670 Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1671 .map(Value::Number)
1672 .ok_or_else(|| "could not encode bool as f64".to_owned()),
1673 Value::Null => Err("null cannot be cast to float".to_owned()),
1674 Value::Array(_) | Value::Object(_) => {
1675 Err("composite values cannot be cast to float".to_owned())
1676 }
1677 },
1678 CastType::Bool => match v {
1679 Value::Bool(b) => Ok(Value::Bool(*b)),
1680 Value::Number(n) => {
1681 if let Some(i) = n.as_i64() {
1682 match i {
1683 0 => Ok(Value::Bool(false)),
1684 1 => Ok(Value::Bool(true)),
1685 _ => Err(format!("integer {i} is not 0 or 1")),
1686 }
1687 } else {
1688 Err(format!("number '{n}' is not 0 or 1"))
1689 }
1690 }
1691 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
1692 "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
1693 "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
1694 other => Err(format!("'{other}' is not a recognised boolean")),
1695 },
1696 Value::Null => Err("null cannot be cast to bool".to_owned()),
1697 Value::Array(_) | Value::Object(_) => {
1698 Err("composite values cannot be cast to bool".to_owned())
1699 }
1700 },
1701 CastType::String => match v {
1702 Value::String(s) => Ok(Value::String(s.clone())),
1703 Value::Number(n) => Ok(Value::String(n.to_string())),
1704 Value::Bool(b) => Ok(Value::String(b.to_string())),
1705 Value::Null => Err("null cannot be cast to string".to_owned()),
1706 Value::Array(_) | Value::Object(_) => {
1707 Err("composite values cannot be cast to string".to_owned())
1708 }
1709 },
1710 CastType::Timestamp => match v {
1711 Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
1712 .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
1713 .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
1714 other => Err(format!(
1715 "cannot cast {} to timestamp (expected RFC 3339 string)",
1716 value_type_name(other)
1717 )),
1718 },
1719 }
1720}
1721
1722#[cfg(feature = "transform-cast")]
1723fn value_type_name(v: &Value) -> &'static str {
1724 match v {
1725 Value::Null => "null",
1726 Value::Bool(_) => "bool",
1727 Value::Number(_) => "number",
1728 Value::String(_) => "string",
1729 Value::Array(_) => "array",
1730 Value::Object(_) => "object",
1731 }
1732}
1733
1734#[cfg(feature = "transform-redact")]
1737fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
1738 match value {
1739 Value::Object(mut map) => {
1740 for f in fields {
1741 if map.contains_key(f) {
1742 map.insert(f.clone(), mask.clone());
1743 }
1744 }
1745 Value::Object(map)
1746 }
1747 other => other,
1748 }
1749}
1750
1751#[cfg(feature = "transform-value-case")]
1754fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
1755 match value {
1756 Value::Object(mut map) => {
1757 for f in fields {
1758 if let Some(Value::String(s)) = map.get(f) {
1759 let new_s = match mode {
1760 ValueCaseMode::Lower => s.to_lowercase(),
1761 ValueCaseMode::Upper => s.to_uppercase(),
1762 ValueCaseMode::Trim => s.trim().to_owned(),
1763 ValueCaseMode::Title => title_case(s),
1764 ValueCaseMode::Capitalize => capitalize_str(s),
1765 };
1766 map.insert(f.clone(), Value::String(new_s));
1767 }
1768 }
1769 Value::Object(map)
1770 }
1771 other => other,
1772 }
1773}
1774
1775#[cfg(feature = "transform-value-case")]
1780fn title_case(s: &str) -> String {
1781 let mut out = String::with_capacity(s.len());
1782 let mut at_word_start = true;
1783 for ch in s.chars() {
1784 if ch.is_whitespace() {
1785 at_word_start = true;
1786 out.push(ch);
1787 } else if at_word_start {
1788 out.extend(ch.to_uppercase());
1789 at_word_start = false;
1790 } else {
1791 out.extend(ch.to_lowercase());
1792 }
1793 }
1794 out
1795}
1796
1797#[cfg(feature = "transform-value-case")]
1800fn capitalize_str(s: &str) -> String {
1801 let mut chars = s.chars();
1802 match chars.next() {
1803 None => String::new(),
1804 Some(first) => {
1805 let mut out: String = first.to_uppercase().collect();
1806 out.push_str(&chars.as_str().to_lowercase());
1807 out
1808 }
1809 }
1810}
1811
1812#[cfg(feature = "transform-spell-symbols")]
1823pub fn default_symbol_map() -> HashMap<String, String> {
1824 let pairs: &[(&str, &str)] = &[
1825 ("%", "percent"),
1826 ("#", "number"),
1827 ("$", "dollar"),
1828 ("&", "and"),
1829 ("@", "at"),
1830 ("+", "plus"),
1831 ("*", "star"),
1832 ("=", "equals"),
1833 ("<", "lt"),
1834 (">", "gt"),
1835 ("/", "slash"),
1836 ("\\", "backslash"),
1837 ("|", "pipe"),
1838 ("^", "caret"),
1839 ("~", "tilde"),
1840 ];
1841 pairs
1842 .iter()
1843 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1844 .collect()
1845}
1846
1847#[cfg(feature = "transform-spell-symbols")]
1848fn spell_symbols(
1849 value: Value,
1850 replacements: &[(String, String)],
1851 separator: &str,
1852) -> Result<Value, FaucetError> {
1853 match value {
1854 Value::Object(map) => {
1855 let mut new_map = Map::with_capacity(map.len());
1856 for (k, v) in map {
1857 let new_k = spell_symbols_in_key(&k, replacements, separator);
1858 let new_v = spell_symbols(v, replacements, separator)?;
1859 if new_map.contains_key(&new_k) {
1863 return Err(FaucetError::Transform(format!(
1864 "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
1865 expand to the same name"
1866 )));
1867 }
1868 new_map.insert(new_k, new_v);
1869 }
1870 Ok(Value::Object(new_map))
1871 }
1872 Value::Array(arr) => {
1873 let mut out = Vec::with_capacity(arr.len());
1874 for v in arr {
1875 out.push(spell_symbols(v, replacements, separator)?);
1876 }
1877 Ok(Value::Array(out))
1878 }
1879 other => Ok(other),
1880 }
1881}
1882
1883#[cfg(feature = "transform-spell-symbols")]
1887fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
1888 let bytes = key.as_bytes();
1892 let mut out = String::with_capacity(key.len());
1893 let mut i = 0;
1894 while i < bytes.len() {
1895 let mut matched = false;
1896 for (from, to) in replacements {
1897 let f = from.as_bytes();
1898 if !f.is_empty() && bytes[i..].starts_with(f) {
1899 out.push_str(separator);
1900 out.push_str(to);
1901 out.push_str(separator);
1902 i += f.len();
1903 matched = true;
1904 break;
1905 }
1906 }
1907 if !matched {
1908 let ch = key[i..]
1911 .chars()
1912 .next()
1913 .expect("non-empty slice yields at least one char");
1914 out.push(ch);
1915 i += ch.len_utf8();
1916 }
1917 }
1918 out
1919}
1920
1921#[cfg(feature = "transform-hash")]
1924fn hash_fields(
1925 value: Value,
1926 fields: &[String],
1927 algorithm: HashAlgorithm,
1928 encoding: HashEncoding,
1929 salt: Option<&str>,
1930 into: Option<&str>,
1931) -> Value {
1932 match value {
1933 Value::Object(mut map) => {
1934 for field in fields {
1935 let Some(current) = map.get(field) else {
1936 continue;
1937 };
1938 if current.is_null() {
1946 continue;
1947 }
1948 let input = match current {
1951 Value::String(s) => s.clone(),
1952 other => other.to_string(),
1953 };
1954 let digest = hash_string(&input, algorithm, encoding, salt);
1955 let target = into.unwrap_or(field.as_str());
1956 map.insert(target.to_owned(), Value::String(digest));
1957 }
1958 Value::Object(map)
1959 }
1960 other => other,
1961 }
1962}
1963
1964#[cfg(feature = "transform-hash")]
1965fn hash_string(
1966 input: &str,
1967 algorithm: HashAlgorithm,
1968 encoding: HashEncoding,
1969 salt: Option<&str>,
1970) -> String {
1971 let mut bytes: Vec<u8> = Vec::with_capacity(salt.map_or(0, str::len) + input.len());
1973 if let Some(s) = salt {
1974 bytes.extend_from_slice(s.as_bytes());
1975 }
1976 bytes.extend_from_slice(input.as_bytes());
1977 let digest: Vec<u8> = match algorithm {
1978 HashAlgorithm::Sha256 => {
1979 use sha2::{Digest, Sha256};
1980 let mut h = Sha256::new();
1981 h.update(&bytes);
1982 h.finalize().to_vec()
1983 }
1984 HashAlgorithm::Blake3 => blake3::hash(&bytes).as_bytes().to_vec(),
1985 };
1986 match encoding {
1987 HashEncoding::Hex => hex_encode(&digest),
1988 HashEncoding::Base64 => {
1989 use base64::Engine;
1990 base64::engine::general_purpose::STANDARD.encode(&digest)
1991 }
1992 }
1993}
1994
1995#[cfg(feature = "transform-hash")]
1996fn hex_encode(bytes: &[u8]) -> String {
1997 const HEX: &[u8; 16] = b"0123456789abcdef";
1998 let mut s = String::with_capacity(bytes.len() * 2);
1999 for &b in bytes {
2000 s.push(HEX[(b >> 4) as usize] as char);
2001 s.push(HEX[(b & 0x0f) as usize] as char);
2002 }
2003 s
2004}
2005
2006#[cfg(feature = "transform-json-parse")]
2009fn json_parse_fields(
2010 value: Value,
2011 fields: &[String],
2012 on_error: JsonParseOnError,
2013 into: Option<&str>,
2014) -> Result<Value, FaucetError> {
2015 match value {
2016 Value::Object(mut map) => {
2017 for field in fields {
2018 let Some(Value::String(s)) = map.get(field) else {
2021 continue;
2022 };
2023 let s = s.clone();
2024 match serde_json::from_str::<Value>(&s) {
2025 Ok(parsed) => {
2026 let target = into.unwrap_or(field.as_str());
2027 map.insert(target.to_owned(), parsed);
2028 }
2029 Err(e) => match on_error {
2030 JsonParseOnError::Keep => { }
2031 JsonParseOnError::Null => {
2032 let target = into.unwrap_or(field.as_str());
2033 map.insert(target.to_owned(), Value::Null);
2034 }
2035 JsonParseOnError::Error => {
2036 return Err(FaucetError::Transform(format!(
2037 "json_parse: field '{field}' is not valid JSON: {e}"
2038 )));
2039 }
2040 },
2041 }
2042 }
2043 Ok(Value::Object(map))
2044 }
2045 other => Ok(other),
2046 }
2047}
2048
2049#[cfg(feature = "transform-coalesce")]
2052fn coalesce_field(
2053 value: Value,
2054 field: &str,
2055 default: Option<&Value>,
2056 from: &[String],
2057 treat_empty_string_as_null: bool,
2058) -> Value {
2059 match value {
2060 Value::Object(mut map) => {
2061 if is_nullish(map.get(field), treat_empty_string_as_null) {
2062 let replacement: Option<Value> = match default {
2063 Some(d) => Some(d.clone()),
2064 None => from.iter().find_map(|k| {
2065 let v = map.get(k);
2066 if is_nullish(v, treat_empty_string_as_null) {
2067 None
2068 } else {
2069 v.cloned()
2070 }
2071 }),
2072 };
2073 if let Some(v) = replacement {
2074 map.insert(field.to_owned(), v);
2075 }
2076 }
2077 Value::Object(map)
2078 }
2079 other => other,
2080 }
2081}
2082
2083#[cfg(feature = "transform-coalesce")]
2086fn is_nullish(v: Option<&Value>, treat_empty_string_as_null: bool) -> bool {
2087 match v {
2088 None | Some(Value::Null) => true,
2089 Some(Value::String(s)) => treat_empty_string_as_null && s.is_empty(),
2090 _ => false,
2091 }
2092}
2093
2094#[cfg(feature = "transform-split-join")]
2097fn split_field(
2098 value: Value,
2099 field: &str,
2100 delimiter: &str,
2101 trim: bool,
2102 into: Option<&str>,
2103) -> Value {
2104 match value {
2105 Value::Object(mut map) => {
2106 let Some(Value::String(s)) = map.get(field) else {
2107 return Value::Object(map);
2108 };
2109 let s = s.clone();
2110 let parts: Vec<Value> = if delimiter.is_empty() {
2114 vec![Value::String(if trim { s.trim().to_owned() } else { s })]
2115 } else {
2116 s.split(delimiter)
2117 .map(|part| {
2118 let p = if trim { part.trim() } else { part };
2119 Value::String(p.to_owned())
2120 })
2121 .collect()
2122 };
2123 let target = into.unwrap_or(field);
2124 map.insert(target.to_owned(), Value::Array(parts));
2125 Value::Object(map)
2126 }
2127 other => other,
2128 }
2129}
2130
2131#[cfg(feature = "transform-split-join")]
2132fn join_field(value: Value, field: &str, delimiter: &str, into: Option<&str>) -> Value {
2133 match value {
2134 Value::Object(mut map) => {
2135 let Some(Value::Array(arr)) = map.get(field) else {
2136 return Value::Object(map);
2137 };
2138 let joined = arr
2139 .iter()
2140 .map(scalar_to_string)
2141 .collect::<Vec<_>>()
2142 .join(delimiter);
2143 let target = into.unwrap_or(field);
2144 map.insert(target.to_owned(), Value::String(joined));
2145 Value::Object(map)
2146 }
2147 other => other,
2148 }
2149}
2150
2151#[cfg(feature = "transform-split-join")]
2154fn scalar_to_string(v: &Value) -> String {
2155 match v {
2156 Value::String(s) => s.clone(),
2157 Value::Null => String::new(),
2158 other => other.to_string(),
2159 }
2160}
2161
2162#[cfg(test)]
2165mod tests {
2166 use super::*;
2167 use serde_json::json;
2168
2169 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
2174 super::apply_all(record, transforms).expect("transform should succeed in this test")
2175 }
2176
2177 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
2178 transforms.iter().map(|t| compile(t).unwrap()).collect()
2179 }
2180
2181 #[test]
2184 fn test_custom_adds_field() {
2185 let record = json!({"id": 1});
2186 let result = apply_all(
2187 record,
2188 &compiled(&[RecordTransform::custom(|mut v| {
2189 if let Value::Object(ref mut m) = v {
2190 m.insert("added".to_string(), json!(true));
2191 }
2192 v
2193 })]),
2194 );
2195 assert_eq!(result["id"], 1);
2196 assert_eq!(result["added"], true);
2197 }
2198
2199 #[test]
2200 fn test_custom_removes_field() {
2201 let record = json!({"id": 1, "secret": "drop_me"});
2202 let result = apply_all(
2203 record,
2204 &compiled(&[RecordTransform::custom(|mut v| {
2205 if let Value::Object(ref mut m) = v {
2206 m.remove("secret");
2207 }
2208 v
2209 })]),
2210 );
2211 assert_eq!(result["id"], 1);
2212 assert!(result.get("secret").is_none());
2213 }
2214
2215 #[test]
2216 fn test_no_transforms_is_identity() {
2217 let record = json!({"id": 1, "name": "Alice"});
2218 let result = apply_all(record.clone(), &[]);
2219 assert_eq!(result, record);
2220 }
2221
2222 #[cfg(feature = "transform-flatten")]
2225 #[test]
2226 fn test_flatten_nested_object() {
2227 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
2228 let result = apply_all(
2229 record,
2230 &compiled(&[RecordTransform::Flatten {
2231 separator: "__".into(),
2232 }]),
2233 );
2234 assert_eq!(result["a__b"], 1);
2235 assert_eq!(result["a__c__d"], 2);
2236 assert_eq!(result["e"], 3);
2237 assert!(result.get("a").is_none(), "nested key should be removed");
2238 }
2239
2240 #[cfg(feature = "transform-flatten")]
2241 #[test]
2242 fn test_flatten_leaves_arrays_intact() {
2243 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
2244 let result = apply_all(
2245 record,
2246 &compiled(&[RecordTransform::Flatten {
2247 separator: ".".into(),
2248 }]),
2249 );
2250 assert_eq!(result["tags"], json!(["rust", "api"]));
2251 assert_eq!(result["meta.count"], 2);
2252 }
2253
2254 #[cfg(feature = "transform-flatten")]
2255 #[test]
2256 fn test_flatten_already_flat() {
2257 let record = json!({"id": 1, "name": "Alice"});
2258 let result = apply_all(
2259 record.clone(),
2260 &compiled(&[RecordTransform::Flatten {
2261 separator: "__".into(),
2262 }]),
2263 );
2264 assert_eq!(result, record);
2265 }
2266
2267 #[cfg(feature = "transform-flatten")]
2268 #[test]
2269 fn test_flatten_empty_separator() {
2270 let record = json!({"a": {"b": 1}});
2271 let result = apply_all(
2272 record,
2273 &compiled(&[RecordTransform::Flatten {
2274 separator: "".into(),
2275 }]),
2276 );
2277 assert_eq!(result["ab"], 1);
2278 }
2279
2280 #[cfg(feature = "transform-rename-keys")]
2283 #[test]
2284 fn test_rename_keys_strips_prefix() {
2285 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
2286 let result = apply_all(
2287 record,
2288 &compiled(&[RecordTransform::RenameKeys {
2289 pattern: r"^_prefix_".into(),
2290 replacement: "".into(),
2291 }]),
2292 );
2293 assert_eq!(result["id"], 1);
2294 assert_eq!(result["name"], "Alice");
2295 }
2296
2297 #[cfg(feature = "transform-rename-keys")]
2298 #[test]
2299 fn test_rename_keys_uppercase_to_placeholder() {
2300 let record = json!({"OUTER": {"INNER": 42}});
2301 let result = apply_all(
2302 record,
2303 &compiled(&[RecordTransform::RenameKeys {
2304 pattern: r"[A-Z]+".into(),
2305 replacement: "x".into(),
2306 }]),
2307 );
2308 assert_eq!(result["x"]["x"], 42);
2309 }
2310
2311 #[cfg(feature = "transform-rename-keys")]
2312 #[test]
2313 fn test_rename_keys_in_array_elements() {
2314 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
2315 let result = apply_all(
2316 record,
2317 &compiled(&[RecordTransform::RenameKeys {
2318 pattern: r"KEY".into(),
2319 replacement: "key".into(),
2320 }]),
2321 );
2322 assert_eq!(result["items"][0]["key"], 1);
2323 assert_eq!(result["items"][1]["key"], 2);
2324 }
2325
2326 #[cfg(feature = "transform-rename-keys")]
2327 #[test]
2328 fn test_rename_keys_invalid_regex_errors_at_compile() {
2329 let err = compile(&RecordTransform::RenameKeys {
2330 pattern: "[invalid".into(),
2331 replacement: "".into(),
2332 });
2333 assert!(err.is_err());
2334 assert!(matches!(err, Err(FaucetError::Transform(_))));
2335 }
2336
2337 #[cfg(feature = "transform-rename-keys")]
2338 #[test]
2339 fn test_rename_keys_chained() {
2340 let record = json!({"__camelCase__": 1});
2341 let result = apply_all(
2342 record,
2343 &compiled(&[
2344 RecordTransform::RenameKeys {
2345 pattern: r"^_+|_+$".into(),
2346 replacement: "".into(),
2347 },
2348 RecordTransform::RenameKeys {
2349 pattern: r"[A-Z]".into(),
2350 replacement: "_".into(),
2351 },
2352 ]),
2353 );
2354 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2355 assert_eq!(key, "camel_ase");
2356 }
2357
2358 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
2361 #[test]
2362 fn test_keys_case_then_flatten() {
2363 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
2364 let result = apply_all(
2365 record,
2366 &compiled(&[
2367 RecordTransform::KeysCase {
2368 mode: KeyCaseMode::Snake,
2369 },
2370 RecordTransform::Flatten {
2371 separator: "_".into(),
2372 },
2373 ]),
2374 );
2375 assert_eq!(result["user_info_first_name"], "Alice");
2376 assert_eq!(result["user_info_last_name"], "Smith");
2377 }
2378
2379 #[test]
2380 fn test_custom_chained_with_builtin() {
2381 let record = json!({"id": 1, "raw_value": 100});
2383 let result = apply_all(
2384 record,
2385 &compiled(&[
2386 RecordTransform::custom(|mut v| {
2388 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
2389 && let Value::Object(ref mut m) = v
2390 {
2391 m.insert("raw_value".to_string(), json!(n * 2));
2392 }
2393 v
2394 }),
2395 RecordTransform::custom(|mut v| {
2397 if let Value::Object(ref mut m) = v
2398 && let Some(val) = m.remove("raw_value")
2399 {
2400 m.insert("value".to_string(), val);
2401 }
2402 v
2403 }),
2404 ]),
2405 );
2406 assert_eq!(result["id"], 1);
2407 assert_eq!(result["value"], 200);
2408 assert!(result.get("raw_value").is_none());
2409 }
2410
2411 #[cfg(feature = "transform-flatten")]
2414 #[test]
2415 fn flatten_key_collision_errors() {
2416 let record = json!({"a__b": 1, "a": {"b": 2}});
2418 let err = super::apply_all(
2419 record,
2420 &compiled(&[RecordTransform::Flatten {
2421 separator: "__".into(),
2422 }]),
2423 )
2424 .expect_err("colliding flattened keys must error, not drop a value");
2425 assert!(matches!(err, FaucetError::Transform(_)));
2426 assert!(format!("{err}").contains("a__b"), "{err}");
2427 }
2428
2429 #[cfg(feature = "transform-select")]
2432 #[test]
2433 fn select_keeps_only_listed_fields() {
2434 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
2435 let result = apply_all(
2436 record,
2437 &compiled(&[RecordTransform::Select {
2438 fields: vec!["id".into(), "name".into()],
2439 }]),
2440 );
2441 assert_eq!(result["id"], 1);
2442 assert_eq!(result["name"], "Alice");
2443 assert!(result.get("secret").is_none());
2444 }
2445
2446 #[cfg(feature = "transform-select")]
2447 #[test]
2448 fn select_missing_field_is_no_op() {
2449 let record = json!({"id": 1});
2451 let result = apply_all(
2452 record,
2453 &compiled(&[RecordTransform::Select {
2454 fields: vec!["id".into(), "missing".into()],
2455 }]),
2456 );
2457 assert_eq!(result["id"], 1);
2458 assert!(result.get("missing").is_none());
2459 }
2460
2461 #[cfg(feature = "transform-select")]
2462 #[test]
2463 fn select_passes_through_non_object() {
2464 let record = json!([1, 2, 3]);
2465 let result = apply_all(
2466 record.clone(),
2467 &compiled(&[RecordTransform::Select {
2468 fields: vec!["id".into()],
2469 }]),
2470 );
2471 assert_eq!(result, record);
2472 }
2473
2474 #[cfg(feature = "transform-drop")]
2477 #[test]
2478 fn drop_removes_listed_fields() {
2479 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
2480 let result = apply_all(
2481 record,
2482 &compiled(&[RecordTransform::Drop {
2483 fields: vec!["ssn".into()],
2484 }]),
2485 );
2486 assert_eq!(result["id"], 1);
2487 assert_eq!(result["name"], "Alice");
2488 assert!(result.get("ssn").is_none());
2489 }
2490
2491 #[cfg(feature = "transform-drop")]
2492 #[test]
2493 fn drop_missing_field_is_no_op() {
2494 let record = json!({"id": 1});
2495 let result = apply_all(
2496 record,
2497 &compiled(&[RecordTransform::Drop {
2498 fields: vec!["missing".into()],
2499 }]),
2500 );
2501 assert_eq!(result["id"], 1);
2502 }
2503
2504 #[cfg(feature = "transform-set")]
2507 #[test]
2508 fn set_inserts_new_fields() {
2509 let record = json!({"id": 1});
2510 let mut values = Map::new();
2511 values.insert("_source".into(), json!("api"));
2512 values.insert("ingested_at".into(), json!("2026-01-01"));
2513 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2514 assert_eq!(result["id"], 1);
2515 assert_eq!(result["_source"], "api");
2516 assert_eq!(result["ingested_at"], "2026-01-01");
2517 }
2518
2519 #[cfg(feature = "transform-set")]
2520 #[test]
2521 fn set_overwrites_existing_field() {
2522 let record = json!({"_source": "old", "id": 1});
2523 let mut values = Map::new();
2524 values.insert("_source".into(), json!("new"));
2525 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2526 assert_eq!(result["_source"], "new");
2527 assert_eq!(result["id"], 1);
2528 }
2529
2530 #[cfg(feature = "transform-set")]
2531 #[test]
2532 fn set_supports_any_json_value() {
2533 let record = json!({});
2534 let mut values = Map::new();
2535 values.insert("n".into(), json!(42));
2536 values.insert("b".into(), json!(true));
2537 values.insert("arr".into(), json!([1, 2]));
2538 values.insert("obj".into(), json!({"k": "v"}));
2539 values.insert("null".into(), Value::Null);
2540 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2541 assert_eq!(result["n"], 42);
2542 assert_eq!(result["b"], true);
2543 assert_eq!(result["arr"], json!([1, 2]));
2544 assert_eq!(result["obj"]["k"], "v");
2545 assert_eq!(result["null"], Value::Null);
2546 }
2547
2548 #[cfg(feature = "transform-rename-field")]
2551 #[test]
2552 fn rename_field_renames_exact_key() {
2553 let record = json!({"old_name": 1, "keep": 2});
2554 let mut fields = HashMap::new();
2555 fields.insert("old_name".to_owned(), "new_name".to_owned());
2556 let result = apply_all(
2557 record,
2558 &compiled(&[RecordTransform::RenameField { fields }]),
2559 );
2560 assert_eq!(result["new_name"], 1);
2561 assert_eq!(result["keep"], 2);
2562 assert!(result.get("old_name").is_none());
2563 }
2564
2565 #[cfg(feature = "transform-rename-field")]
2566 #[test]
2567 fn rename_field_missing_source_is_no_op() {
2568 let record = json!({"id": 1});
2569 let mut fields = HashMap::new();
2570 fields.insert("missing".to_owned(), "renamed".to_owned());
2571 let result = apply_all(
2572 record,
2573 &compiled(&[RecordTransform::RenameField { fields }]),
2574 );
2575 assert_eq!(result["id"], 1);
2576 assert!(result.get("renamed").is_none());
2577 }
2578
2579 #[cfg(feature = "transform-rename-field")]
2580 #[test]
2581 fn rename_field_target_collision_errors() {
2582 let record = json!({"a": 1, "b": 2});
2583 let mut fields = HashMap::new();
2584 fields.insert("a".to_owned(), "b".to_owned());
2585 let err = super::apply_all(
2586 record,
2587 &compiled(&[RecordTransform::RenameField { fields }]),
2588 )
2589 .expect_err("collision must error, not overwrite");
2590 assert!(matches!(err, FaucetError::Transform(_)));
2591 assert!(format!("{err}").contains("'b'"), "{err}");
2592 }
2593
2594 #[cfg(feature = "transform-rename-field")]
2595 #[test]
2596 fn rename_field_swap_is_deterministic() {
2597 for _ in 0..50 {
2600 let record = json!({"a": 1, "b": 2, "keep": 3});
2601 let mut fields = HashMap::new();
2602 fields.insert("a".to_owned(), "b".to_owned());
2603 fields.insert("b".to_owned(), "a".to_owned());
2604 let result = apply_all(
2605 record,
2606 &compiled(&[RecordTransform::RenameField { fields }]),
2607 );
2608 assert_eq!(result["a"], 2, "{result}");
2609 assert_eq!(result["b"], 1, "{result}");
2610 assert_eq!(result["keep"], 3);
2611 }
2612 }
2613
2614 #[cfg(feature = "transform-rename-field")]
2615 #[test]
2616 fn rename_field_chain_applies_against_original_snapshot() {
2617 for _ in 0..50 {
2620 let record = json!({"a": 1, "b": 2});
2621 let mut fields = HashMap::new();
2622 fields.insert("a".to_owned(), "b".to_owned());
2623 fields.insert("b".to_owned(), "c".to_owned());
2624 let result = apply_all(
2625 record,
2626 &compiled(&[RecordTransform::RenameField { fields }]),
2627 );
2628 assert_eq!(result["b"], 1, "{result}");
2629 assert_eq!(result["c"], 2, "{result}");
2630 assert!(result.get("a").is_none(), "{result}");
2631 }
2632 }
2633
2634 #[cfg(feature = "transform-rename-field")]
2635 #[test]
2636 fn rename_field_two_sources_one_target_errors() {
2637 let record = json!({"a": 1, "b": 2});
2638 let mut fields = HashMap::new();
2639 fields.insert("a".to_owned(), "c".to_owned());
2640 fields.insert("b".to_owned(), "c".to_owned());
2641 let err = super::apply_all(
2642 record,
2643 &compiled(&[RecordTransform::RenameField { fields }]),
2644 )
2645 .expect_err("two renames to the same target must error");
2646 assert!(format!("{err}").contains("same target"), "{err}");
2647 }
2648
2649 #[cfg(feature = "transform-cast")]
2652 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
2653 let mut fields = HashMap::new();
2654 fields.insert(field.to_owned(), ty);
2655 vec![RecordTransform::Cast { fields, on_error }]
2656 }
2657
2658 #[cfg(feature = "transform-cast")]
2659 #[test]
2660 fn cast_string_to_int() {
2661 let record = json!({"age": "42"});
2662 let result = apply_all(
2663 record,
2664 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
2665 );
2666 assert_eq!(result["age"], 42);
2667 }
2668
2669 #[cfg(feature = "transform-cast")]
2670 #[test]
2671 fn cast_whole_number_float_to_int_succeeds() {
2672 let record = json!({"n": 5.0});
2674 let result = apply_all(
2675 record,
2676 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2677 );
2678 assert_eq!(result["n"], 5);
2679 }
2680
2681 #[cfg(feature = "transform-cast")]
2682 #[test]
2683 fn cast_fractional_float_to_int_errors_under_on_error_error() {
2684 let record = json!({"n": 3.9});
2686 let err = super::apply_all(
2687 record,
2688 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2689 )
2690 .expect_err("a fractional float must not silently truncate to int");
2691 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
2692 }
2693
2694 #[cfg(feature = "transform-cast")]
2695 #[test]
2696 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
2697 let record = json!({"n": 1e30});
2699 let err = super::apply_all(
2700 record,
2701 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2702 )
2703 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
2704 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
2705 }
2706
2707 #[cfg(feature = "transform-cast")]
2708 #[test]
2709 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
2710 let record = json!({"n": 3.9});
2711 let result = apply_all(
2712 record,
2713 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
2714 );
2715 assert_eq!(result["n"], Value::Null);
2716 }
2717
2718 #[cfg(feature = "transform-cast")]
2719 #[test]
2720 fn cast_string_to_float() {
2721 let record = json!({"price": "9.99"});
2722 let result = apply_all(
2723 record,
2724 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
2725 );
2726 assert_eq!(result["price"], 9.99);
2727 }
2728
2729 #[cfg(feature = "transform-cast")]
2730 #[test]
2731 fn cast_string_to_bool() {
2732 for input in ["true", "TRUE", "1", "yes"] {
2733 let record = json!({"flag": input});
2734 let result = apply_all(
2735 record,
2736 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2737 );
2738 assert_eq!(result["flag"], true, "input was {input:?}");
2739 }
2740 for input in ["false", "0", "no"] {
2741 let record = json!({"flag": input});
2742 let result = apply_all(
2743 record,
2744 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2745 );
2746 assert_eq!(result["flag"], false, "input was {input:?}");
2747 }
2748 }
2749
2750 #[cfg(feature = "transform-cast")]
2751 #[test]
2752 fn cast_number_to_string() {
2753 let record = json!({"id": 42});
2754 let result = apply_all(
2755 record,
2756 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
2757 );
2758 assert_eq!(result["id"], "42");
2759 }
2760
2761 #[cfg(feature = "transform-cast")]
2762 #[test]
2763 fn cast_string_to_timestamp_normalises() {
2764 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
2765 let result = apply_all(
2766 record,
2767 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
2768 );
2769 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
2771 }
2772
2773 #[cfg(feature = "transform-cast")]
2774 #[test]
2775 fn cast_on_error_error_propagates() {
2776 let record = json!({"age": "not a number"});
2777 let err = super::apply_all(
2778 record,
2779 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
2780 )
2781 .expect_err("uncastable value must error under on_error=error");
2782 assert!(matches!(err, FaucetError::Transform(_)));
2783 assert!(format!("{err}").contains("'age'"), "{err}");
2784 }
2785
2786 #[cfg(feature = "transform-cast")]
2787 #[test]
2788 fn cast_on_error_null_replaces() {
2789 let record = json!({"age": "not a number"});
2790 let result = apply_all(
2791 record,
2792 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
2793 );
2794 assert_eq!(result["age"], Value::Null);
2795 }
2796
2797 #[cfg(feature = "transform-cast")]
2798 #[test]
2799 fn cast_on_error_skip_leaves_value() {
2800 let record = json!({"age": "not a number"});
2801 let result = apply_all(
2802 record,
2803 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
2804 );
2805 assert_eq!(result["age"], "not a number");
2806 }
2807
2808 #[cfg(feature = "transform-cast")]
2809 #[test]
2810 fn cast_missing_field_is_no_op() {
2811 let record = json!({"id": 1});
2812 let result = apply_all(
2813 record,
2814 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
2815 );
2816 assert_eq!(result["id"], 1);
2817 assert!(result.get("missing").is_none());
2818 }
2819
2820 #[cfg(feature = "transform-redact")]
2823 #[test]
2824 fn redact_replaces_value_with_mask() {
2825 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
2826 let result = apply_all(
2827 record,
2828 &compiled(&[RecordTransform::Redact {
2829 fields: vec!["ssn".into(), "email".into()],
2830 mask: json!("***"),
2831 }]),
2832 );
2833 assert_eq!(result["id"], 1);
2834 assert_eq!(result["ssn"], "***");
2835 assert_eq!(result["email"], "***");
2836 }
2837
2838 #[cfg(feature = "transform-redact")]
2839 #[test]
2840 fn redact_missing_field_does_not_insert_mask() {
2841 let record = json!({"id": 1});
2842 let result = apply_all(
2843 record,
2844 &compiled(&[RecordTransform::Redact {
2845 fields: vec!["ssn".into()],
2846 mask: json!("***"),
2847 }]),
2848 );
2849 assert_eq!(result["id"], 1);
2850 assert!(result.get("ssn").is_none());
2851 }
2852
2853 #[cfg(feature = "transform-value-case")]
2856 #[test]
2857 fn value_case_lower() {
2858 let record = json!({"email": "User@Example.COM", "id": 1});
2859 let result = apply_all(
2860 record,
2861 &compiled(&[RecordTransform::ValueCase {
2862 fields: vec!["email".into()],
2863 mode: ValueCaseMode::Lower,
2864 }]),
2865 );
2866 assert_eq!(result["email"], "user@example.com");
2867 assert_eq!(result["id"], 1);
2868 }
2869
2870 #[cfg(feature = "transform-value-case")]
2871 #[test]
2872 fn value_case_upper() {
2873 let record = json!({"code": "abc"});
2874 let result = apply_all(
2875 record,
2876 &compiled(&[RecordTransform::ValueCase {
2877 fields: vec!["code".into()],
2878 mode: ValueCaseMode::Upper,
2879 }]),
2880 );
2881 assert_eq!(result["code"], "ABC");
2882 }
2883
2884 #[cfg(feature = "transform-value-case")]
2885 #[test]
2886 fn value_case_trim() {
2887 let record = json!({"name": " Alice "});
2888 let result = apply_all(
2889 record,
2890 &compiled(&[RecordTransform::ValueCase {
2891 fields: vec!["name".into()],
2892 mode: ValueCaseMode::Trim,
2893 }]),
2894 );
2895 assert_eq!(result["name"], "Alice");
2896 }
2897
2898 #[cfg(feature = "transform-value-case")]
2899 #[test]
2900 fn value_case_passes_non_string_through() {
2901 let record = json!({"id": 42});
2902 let result = apply_all(
2903 record,
2904 &compiled(&[RecordTransform::ValueCase {
2905 fields: vec!["id".into()],
2906 mode: ValueCaseMode::Upper,
2907 }]),
2908 );
2909 assert_eq!(result["id"], 42);
2910 }
2911
2912 #[cfg(feature = "transform-spell-symbols")]
2915 fn spell_default() -> Vec<RecordTransform> {
2916 vec![RecordTransform::SpellSymbols {
2917 extra: HashMap::new(),
2918 separator: " ".into(),
2919 }]
2920 }
2921
2922 #[cfg(feature = "transform-spell-symbols")]
2923 #[test]
2924 fn spell_symbols_replaces_common_symbols() {
2925 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
2926 let result = apply_all(record, &compiled(&spell_default()));
2927 assert!(result.get(" percent sold").is_some());
2930 assert!(result.get("C number course").is_some());
2931 assert!(result.get(" dollar amount").is_some());
2932 }
2933
2934 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
2935 #[test]
2936 fn spell_symbols_then_keys_case_pipeline() {
2937 let record = json!({"% sold": 10, "C# courses": 20});
2938 let result = super::apply_all(
2939 record,
2940 &compiled(&[
2941 RecordTransform::SpellSymbols {
2942 extra: HashMap::new(),
2943 separator: " ".into(),
2944 },
2945 RecordTransform::KeysCase {
2946 mode: KeyCaseMode::Snake,
2947 },
2948 ]),
2949 )
2950 .expect("pipeline must succeed");
2951 assert_eq!(result["percent_sold"], 10);
2952 assert_eq!(result["c_number_courses"], 20);
2953 }
2954
2955 #[cfg(feature = "transform-spell-symbols")]
2956 #[test]
2957 fn spell_symbols_extra_overrides_defaults() {
2958 let mut extra = HashMap::new();
2959 extra.insert("#".to_owned(), "hash".to_owned());
2960 extra.insert("©".to_owned(), "copyright".to_owned());
2961 let record = json!({"#tag": 1, "©2026": 2});
2962 let result = apply_all(
2963 record,
2964 &compiled(&[RecordTransform::SpellSymbols {
2965 extra,
2966 separator: " ".into(),
2967 }]),
2968 );
2969 assert!(result.get(" hash tag").is_some());
2971 assert!(result.get(" copyright 2026").is_some());
2973 }
2974
2975 #[cfg(feature = "transform-spell-symbols")]
2976 #[test]
2977 fn spell_symbols_longest_match_wins() {
2978 let mut extra = HashMap::new();
2980 extra.insert("<=".to_owned(), "lte".to_owned());
2981 let record = json!({"a<=b": 1});
2982 let result = apply_all(
2983 record,
2984 &compiled(&[RecordTransform::SpellSymbols {
2985 extra,
2986 separator: " ".into(),
2987 }]),
2988 );
2989 assert!(result.get("a lte b").is_some());
2990 assert!(result.get("a lt = b").is_none());
2992 }
2993
2994 #[cfg(feature = "transform-spell-symbols")]
2995 #[test]
2996 fn spell_symbols_recursive_into_objects_and_arrays() {
2997 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
2998 let result = apply_all(record, &compiled(&spell_default()));
2999 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
3000 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
3001 let inner = &result[&outer_key];
3002 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
3003 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
3004 let deep = &inner[&inner_key][0];
3005 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
3006 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
3007 }
3008
3009 #[cfg(feature = "transform-spell-symbols")]
3010 #[test]
3011 fn spell_symbols_key_collision_errors() {
3012 let record = json!({"%": 1, "percent": 2});
3014 let err = super::apply_all(
3015 record,
3016 &compiled(&[RecordTransform::SpellSymbols {
3017 extra: HashMap::new(),
3018 separator: "".into(),
3019 }]),
3020 )
3021 .expect_err("colliding spelled keys must error, not drop a value");
3022 assert!(matches!(err, FaucetError::Transform(_)));
3023 assert!(format!("{err}").contains("percent"), "{err}");
3024 }
3025
3026 #[cfg(feature = "transform-keys-case")]
3029 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
3030 vec![RecordTransform::KeysCase { mode }]
3031 }
3032
3033 #[cfg(feature = "transform-keys-case")]
3034 #[test]
3035 fn keys_case_snake() {
3036 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
3037 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3038 assert_eq!(result["first_name"], 1);
3039 assert_eq!(result["last_name"], 2);
3040 assert_eq!(result["id"], 3);
3041 }
3042
3043 #[cfg(feature = "transform-keys-case")]
3044 #[test]
3045 fn keys_case_camel_from_various_inputs() {
3046 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
3048 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
3049 assert_eq!(result["firstName"], 1);
3050 assert_eq!(result["userId"], 2);
3051 assert_eq!(result["kebabCase"], 3);
3052 assert_eq!(result["pascalCase"], 4);
3053 }
3054
3055 #[cfg(feature = "transform-keys-case")]
3056 #[test]
3057 fn keys_case_pascal() {
3058 let record = json!({"first_name": 1, "second name": 2});
3059 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
3060 assert_eq!(result["FirstName"], 1);
3061 assert_eq!(result["SecondName"], 2);
3062 }
3063
3064 #[cfg(feature = "transform-keys-case")]
3065 #[test]
3066 fn keys_case_kebab() {
3067 let record = json!({"firstName": 1, "second_name": 2});
3068 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
3069 assert_eq!(result["first-name"], 1);
3070 assert_eq!(result["second-name"], 2);
3071 }
3072
3073 #[cfg(feature = "transform-keys-case")]
3074 #[test]
3075 fn keys_case_screaming_snake() {
3076 let record = json!({"firstName": 1, "second name": 2});
3077 let result = apply_all(
3078 record,
3079 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
3080 );
3081 assert_eq!(result["FIRST_NAME"], 1);
3082 assert_eq!(result["SECOND_NAME"], 2);
3083 }
3084
3085 #[cfg(feature = "transform-keys-case")]
3086 #[test]
3087 fn keys_case_recursive_into_nested() {
3088 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
3089 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3090 assert_eq!(result["user_info"]["first_name"], "Alice");
3091 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
3092 }
3093
3094 #[cfg(feature = "transform-keys-case")]
3095 #[test]
3096 fn keys_case_collision_errors() {
3097 let record = json!({"firstName": 1, "first_name": 2});
3099 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
3100 .expect_err("colliding re-cased keys must error, not drop a value");
3101 assert!(matches!(err, FaucetError::Transform(_)));
3102 assert!(format!("{err}").contains("first_name"), "{err}");
3103 }
3104
3105 #[cfg(feature = "transform-keys-case")]
3106 #[test]
3107 fn keys_case_all_symbol_key_kept_as_is() {
3108 let record = json!({"!@#": 1, "id": 2});
3111 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3112 assert_eq!(result["!@#"], 1);
3113 assert_eq!(result["id"], 2);
3114 }
3115
3116 #[cfg(feature = "transform-keys-case")]
3117 #[test]
3118 fn keys_case_idempotent_in_target_mode() {
3119 let record = json!({"first_name": 1});
3122 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3123 let twice = apply_all(
3124 once.clone(),
3125 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
3126 );
3127 assert_eq!(once, twice);
3128 }
3129
3130 #[cfg(feature = "transform-spell-symbols")]
3131 #[test]
3132 fn spell_symbols_handles_unicode_keys() {
3133 let record = json!({"café%": 1});
3135 let result = apply_all(record, &compiled(&spell_default()));
3136 let key = result.as_object().unwrap().keys().next().unwrap().clone();
3137 assert!(key.contains("café"), "key was {key:?}");
3138 assert!(key.contains("percent"), "key was {key:?}");
3139 }
3140
3141 #[test]
3144 fn debug_record_transform_all_variants() {
3145 let dbg = format!("{:?}", RecordTransform::custom(|v| v));
3147 assert_eq!(dbg, "Custom(<fn>)");
3148
3149 #[cfg(feature = "transform-flatten")]
3150 {
3151 let dbg = format!(
3152 "{:?}",
3153 RecordTransform::Flatten {
3154 separator: "__".into()
3155 }
3156 );
3157 assert!(dbg.starts_with("Flatten"), "{dbg}");
3158 assert!(dbg.contains("separator"), "{dbg}");
3159 assert!(dbg.contains("__"), "{dbg}");
3160 }
3161 #[cfg(feature = "transform-rename-keys")]
3162 {
3163 let dbg = format!(
3164 "{:?}",
3165 RecordTransform::RenameKeys {
3166 pattern: "p".into(),
3167 replacement: "r".into(),
3168 }
3169 );
3170 assert!(dbg.starts_with("RenameKeys"), "{dbg}");
3171 assert!(dbg.contains("pattern"), "{dbg}");
3172 assert!(dbg.contains("replacement"), "{dbg}");
3173 }
3174 #[cfg(feature = "transform-keys-case")]
3175 {
3176 let dbg = format!(
3177 "{:?}",
3178 RecordTransform::KeysCase {
3179 mode: KeyCaseMode::Snake
3180 }
3181 );
3182 assert!(dbg.starts_with("KeysCase"), "{dbg}");
3183 assert!(dbg.contains("Snake"), "{dbg}");
3184 }
3185 #[cfg(feature = "transform-select")]
3186 {
3187 let dbg = format!(
3188 "{:?}",
3189 RecordTransform::Select {
3190 fields: vec!["a".into()]
3191 }
3192 );
3193 assert!(dbg.starts_with("Select"), "{dbg}");
3194 assert!(dbg.contains("fields"), "{dbg}");
3195 }
3196 #[cfg(feature = "transform-drop")]
3197 {
3198 let dbg = format!(
3199 "{:?}",
3200 RecordTransform::Drop {
3201 fields: vec!["a".into()]
3202 }
3203 );
3204 assert!(dbg.starts_with("Drop"), "{dbg}");
3205 }
3206 #[cfg(feature = "transform-set")]
3207 {
3208 let mut values = Map::new();
3209 values.insert("k".into(), json!("v"));
3210 let dbg = format!("{:?}", RecordTransform::Set { values });
3211 assert!(dbg.starts_with("Set"), "{dbg}");
3212 assert!(dbg.contains("values"), "{dbg}");
3213 }
3214 #[cfg(feature = "transform-rename-field")]
3215 {
3216 let mut fields = HashMap::new();
3217 fields.insert("a".to_owned(), "b".to_owned());
3218 let dbg = format!("{:?}", RecordTransform::RenameField { fields });
3219 assert!(dbg.starts_with("RenameField"), "{dbg}");
3220 }
3221 #[cfg(feature = "transform-cast")]
3222 {
3223 let mut fields = HashMap::new();
3224 fields.insert("a".to_owned(), CastType::Int);
3225 let dbg = format!(
3226 "{:?}",
3227 RecordTransform::Cast {
3228 fields,
3229 on_error: CastOnError::Error,
3230 }
3231 );
3232 assert!(dbg.starts_with("Cast"), "{dbg}");
3233 assert!(dbg.contains("on_error"), "{dbg}");
3234 }
3235 #[cfg(feature = "transform-redact")]
3236 {
3237 let dbg = format!(
3238 "{:?}",
3239 RecordTransform::Redact {
3240 fields: vec!["a".into()],
3241 mask: json!("***"),
3242 }
3243 );
3244 assert!(dbg.starts_with("Redact"), "{dbg}");
3245 assert!(dbg.contains("mask"), "{dbg}");
3246 }
3247 #[cfg(feature = "transform-value-case")]
3248 {
3249 let dbg = format!(
3250 "{:?}",
3251 RecordTransform::ValueCase {
3252 fields: vec!["a".into()],
3253 mode: ValueCaseMode::Lower,
3254 }
3255 );
3256 assert!(dbg.starts_with("ValueCase"), "{dbg}");
3257 assert!(dbg.contains("mode"), "{dbg}");
3258 }
3259 #[cfg(feature = "transform-spell-symbols")]
3260 {
3261 let dbg = format!(
3262 "{:?}",
3263 RecordTransform::SpellSymbols {
3264 extra: HashMap::new(),
3265 separator: " ".into(),
3266 }
3267 );
3268 assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
3269 assert!(dbg.contains("separator"), "{dbg}");
3270 }
3271 #[cfg(feature = "transform-hash")]
3272 {
3273 let dbg = format!(
3274 "{:?}",
3275 RecordTransform::Hash {
3276 fields: vec!["a".into()],
3277 algorithm: HashAlgorithm::Blake3,
3278 encoding: HashEncoding::Base64,
3279 salt: Some("s".into()),
3280 into: Some("a_hash".into()),
3281 }
3282 );
3283 assert!(dbg.starts_with("Hash"), "{dbg}");
3284 assert!(dbg.contains("algorithm"), "{dbg}");
3285 assert!(dbg.contains("encoding"), "{dbg}");
3286 }
3287 #[cfg(feature = "transform-json-parse")]
3288 {
3289 let dbg = format!(
3290 "{:?}",
3291 RecordTransform::JsonParse {
3292 fields: vec!["a".into()],
3293 on_error: JsonParseOnError::Null,
3294 into: None,
3295 }
3296 );
3297 assert!(dbg.starts_with("JsonParse"), "{dbg}");
3298 assert!(dbg.contains("on_error"), "{dbg}");
3299 }
3300 #[cfg(feature = "transform-coalesce")]
3301 {
3302 let dbg = format!(
3303 "{:?}",
3304 RecordTransform::Coalesce {
3305 field: "a".into(),
3306 default: Some(json!("x")),
3307 from: vec![],
3308 treat_empty_string_as_null: true,
3309 }
3310 );
3311 assert!(dbg.starts_with("Coalesce"), "{dbg}");
3312 assert!(dbg.contains("treat_empty_string_as_null"), "{dbg}");
3313 }
3314 #[cfg(feature = "transform-split-join")]
3315 {
3316 let dbg = format!(
3317 "{:?}",
3318 RecordTransform::Split {
3319 field: "a".into(),
3320 delimiter: ",".into(),
3321 trim: true,
3322 into: None,
3323 }
3324 );
3325 assert!(dbg.starts_with("Split"), "{dbg}");
3326 assert!(dbg.contains("delimiter"), "{dbg}");
3327 let dbg = format!(
3328 "{:?}",
3329 RecordTransform::Join {
3330 field: "a".into(),
3331 delimiter: ",".into(),
3332 into: None,
3333 }
3334 );
3335 assert!(dbg.starts_with("Join"), "{dbg}");
3336 }
3337 }
3338
3339 #[test]
3342 fn clone_record_transform_custom_preserves_behaviour() {
3343 let original = RecordTransform::custom(|mut v| {
3344 if let Value::Object(ref mut m) = v {
3345 m.insert("cloned".into(), json!(true));
3346 }
3347 v
3348 });
3349 let cloned = original.clone();
3350 assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
3351 let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
3352 assert_eq!(out["cloned"], true);
3353 assert_eq!(out["id"], 1);
3354 }
3355
3356 #[test]
3357 #[allow(clippy::vec_init_then_push)]
3360 fn clone_record_transform_all_builtin_variants() {
3361 let mut variants: Vec<RecordTransform> = Vec::new();
3362 #[cfg(feature = "transform-flatten")]
3363 variants.push(RecordTransform::Flatten {
3364 separator: "__".into(),
3365 });
3366 #[cfg(feature = "transform-rename-keys")]
3367 variants.push(RecordTransform::RenameKeys {
3368 pattern: "p".into(),
3369 replacement: "r".into(),
3370 });
3371 #[cfg(feature = "transform-keys-case")]
3372 variants.push(RecordTransform::KeysCase {
3373 mode: KeyCaseMode::Snake,
3374 });
3375 #[cfg(feature = "transform-select")]
3376 variants.push(RecordTransform::Select {
3377 fields: vec!["a".into()],
3378 });
3379 #[cfg(feature = "transform-drop")]
3380 variants.push(RecordTransform::Drop {
3381 fields: vec!["a".into()],
3382 });
3383 #[cfg(feature = "transform-set")]
3384 {
3385 let mut values = Map::new();
3386 values.insert("k".into(), json!("v"));
3387 variants.push(RecordTransform::Set { values });
3388 }
3389 #[cfg(feature = "transform-rename-field")]
3390 {
3391 let mut fields = HashMap::new();
3392 fields.insert("a".to_owned(), "b".to_owned());
3393 variants.push(RecordTransform::RenameField { fields });
3394 }
3395 #[cfg(feature = "transform-cast")]
3396 {
3397 let mut fields = HashMap::new();
3398 fields.insert("a".to_owned(), CastType::Int);
3399 variants.push(RecordTransform::Cast {
3400 fields,
3401 on_error: CastOnError::Error,
3402 });
3403 }
3404 #[cfg(feature = "transform-redact")]
3405 variants.push(RecordTransform::Redact {
3406 fields: vec!["a".into()],
3407 mask: json!("***"),
3408 });
3409 #[cfg(feature = "transform-value-case")]
3410 variants.push(RecordTransform::ValueCase {
3411 fields: vec!["a".into()],
3412 mode: ValueCaseMode::Lower,
3413 });
3414 #[cfg(feature = "transform-spell-symbols")]
3415 variants.push(RecordTransform::SpellSymbols {
3416 extra: HashMap::new(),
3417 separator: " ".into(),
3418 });
3419 #[cfg(feature = "transform-hash")]
3420 variants.push(RecordTransform::Hash {
3421 fields: vec!["a".into()],
3422 algorithm: HashAlgorithm::Sha256,
3423 encoding: HashEncoding::Hex,
3424 salt: Some("s".into()),
3425 into: None,
3426 });
3427 #[cfg(feature = "transform-json-parse")]
3428 variants.push(RecordTransform::JsonParse {
3429 fields: vec!["a".into()],
3430 on_error: JsonParseOnError::Keep,
3431 into: Some("b".into()),
3432 });
3433 #[cfg(feature = "transform-coalesce")]
3434 variants.push(RecordTransform::Coalesce {
3435 field: "a".into(),
3436 default: None,
3437 from: vec!["b".into()],
3438 treat_empty_string_as_null: false,
3439 });
3440 #[cfg(feature = "transform-split-join")]
3441 {
3442 variants.push(RecordTransform::Split {
3443 field: "a".into(),
3444 delimiter: ",".into(),
3445 trim: true,
3446 into: None,
3447 });
3448 variants.push(RecordTransform::Join {
3449 field: "a".into(),
3450 delimiter: ",".into(),
3451 into: Some("b".into()),
3452 });
3453 }
3454
3455 for v in &variants {
3457 let cloned = v.clone();
3458 assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
3459 }
3460 }
3461
3462 #[test]
3463 fn clone_compiled_transform_all_variants() {
3464 let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
3465 #[cfg(feature = "transform-flatten")]
3466 specs.push(RecordTransform::Flatten {
3467 separator: "__".into(),
3468 });
3469 #[cfg(feature = "transform-rename-keys")]
3470 specs.push(RecordTransform::RenameKeys {
3471 pattern: "p".into(),
3472 replacement: "r".into(),
3473 });
3474 #[cfg(feature = "transform-keys-case")]
3475 specs.push(RecordTransform::KeysCase {
3476 mode: KeyCaseMode::Camel,
3477 });
3478 #[cfg(feature = "transform-select")]
3479 specs.push(RecordTransform::Select {
3480 fields: vec!["a".into()],
3481 });
3482 #[cfg(feature = "transform-drop")]
3483 specs.push(RecordTransform::Drop {
3484 fields: vec!["a".into()],
3485 });
3486 #[cfg(feature = "transform-set")]
3487 {
3488 let mut values = Map::new();
3489 values.insert("k".into(), json!("v"));
3490 specs.push(RecordTransform::Set { values });
3491 }
3492 #[cfg(feature = "transform-rename-field")]
3493 {
3494 let mut fields = HashMap::new();
3495 fields.insert("a".to_owned(), "b".to_owned());
3496 specs.push(RecordTransform::RenameField { fields });
3497 }
3498 #[cfg(feature = "transform-cast")]
3499 {
3500 let mut fields = HashMap::new();
3501 fields.insert("a".to_owned(), CastType::Int);
3502 specs.push(RecordTransform::Cast {
3503 fields,
3504 on_error: CastOnError::Null,
3505 });
3506 }
3507 #[cfg(feature = "transform-redact")]
3508 specs.push(RecordTransform::Redact {
3509 fields: vec!["a".into()],
3510 mask: json!("***"),
3511 });
3512 #[cfg(feature = "transform-value-case")]
3513 specs.push(RecordTransform::ValueCase {
3514 fields: vec!["a".into()],
3515 mode: ValueCaseMode::Upper,
3516 });
3517 #[cfg(feature = "transform-spell-symbols")]
3518 specs.push(RecordTransform::SpellSymbols {
3519 extra: HashMap::new(),
3520 separator: " ".into(),
3521 });
3522 #[cfg(feature = "transform-hash")]
3523 specs.push(RecordTransform::Hash {
3524 fields: vec!["a".into()],
3525 algorithm: HashAlgorithm::Blake3,
3526 encoding: HashEncoding::Base64,
3527 salt: None,
3528 into: None,
3529 });
3530 #[cfg(feature = "transform-json-parse")]
3531 specs.push(RecordTransform::JsonParse {
3532 fields: vec!["a".into()],
3533 on_error: JsonParseOnError::Error,
3534 into: None,
3535 });
3536 #[cfg(feature = "transform-coalesce")]
3537 specs.push(RecordTransform::Coalesce {
3538 field: "a".into(),
3539 default: Some(json!("x")),
3540 from: vec![],
3541 treat_empty_string_as_null: true,
3542 });
3543 #[cfg(feature = "transform-split-join")]
3544 {
3545 specs.push(RecordTransform::Split {
3546 field: "a".into(),
3547 delimiter: ",".into(),
3548 trim: false,
3549 into: None,
3550 });
3551 specs.push(RecordTransform::Join {
3552 field: "a".into(),
3553 delimiter: ",".into(),
3554 into: None,
3555 });
3556 }
3557
3558 let original = compiled(&specs);
3561 let cloned: Vec<CompiledTransform> = original.to_vec();
3562 assert_eq!(original.len(), cloned.len());
3563 let record = json!({"a": "1", "k": "x"});
3564 let out_orig = super::apply_all(record.clone(), &original);
3565 let out_clone = super::apply_all(record, &cloned);
3566 assert_eq!(
3567 out_orig.is_ok(),
3568 out_clone.is_ok(),
3569 "clone must transform identically"
3570 );
3571 if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
3572 assert_eq!(a, b);
3573 }
3574 }
3575
3576 #[cfg(feature = "transform-flatten")]
3579 #[test]
3580 fn flatten_passes_through_non_object() {
3581 let record = json!([1, 2, 3]);
3582 let result = apply_all(
3583 record.clone(),
3584 &compiled(&[RecordTransform::Flatten {
3585 separator: "__".into(),
3586 }]),
3587 );
3588 assert_eq!(result, record);
3589 let scalar = json!(42);
3591 let result = apply_all(
3592 scalar.clone(),
3593 &compiled(&[RecordTransform::Flatten {
3594 separator: "__".into(),
3595 }]),
3596 );
3597 assert_eq!(result, scalar);
3598 }
3599
3600 #[cfg(feature = "transform-drop")]
3601 #[test]
3602 fn drop_passes_through_non_object() {
3603 let record = json!([1, 2]);
3604 let result = apply_all(
3605 record.clone(),
3606 &compiled(&[RecordTransform::Drop {
3607 fields: vec!["a".into()],
3608 }]),
3609 );
3610 assert_eq!(result, record);
3611 }
3612
3613 #[cfg(feature = "transform-set")]
3614 #[test]
3615 fn set_passes_through_non_object() {
3616 let mut values = Map::new();
3617 values.insert("k".into(), json!("v"));
3618 let record = json!("scalar");
3619 let result = apply_all(
3620 record.clone(),
3621 &compiled(&[RecordTransform::Set { values }]),
3622 );
3623 assert_eq!(result, record);
3624 }
3625
3626 #[cfg(feature = "transform-rename-field")]
3627 #[test]
3628 fn rename_field_passes_through_non_object() {
3629 let mut fields = HashMap::new();
3630 fields.insert("a".to_owned(), "b".to_owned());
3631 let record = json!([1, 2]);
3632 let result = apply_all(
3633 record.clone(),
3634 &compiled(&[RecordTransform::RenameField { fields }]),
3635 );
3636 assert_eq!(result, record);
3637 }
3638
3639 #[cfg(feature = "transform-rename-field")]
3640 #[test]
3641 fn rename_field_same_name_is_skipped() {
3642 let mut fields = HashMap::new();
3644 fields.insert("a".to_owned(), "a".to_owned());
3645 let record = json!({"a": 1});
3646 let result = apply_all(
3647 record,
3648 &compiled(&[RecordTransform::RenameField { fields }]),
3649 );
3650 assert_eq!(result["a"], 1);
3651 }
3652
3653 #[cfg(feature = "transform-cast")]
3654 #[test]
3655 fn cast_passes_through_non_object() {
3656 let record = json!([1, 2]);
3657 let result = apply_all(
3658 record.clone(),
3659 &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
3660 );
3661 assert_eq!(result, record);
3662 }
3663
3664 #[cfg(feature = "transform-redact")]
3665 #[test]
3666 fn redact_passes_through_non_object() {
3667 let record = json!("scalar");
3668 let result = apply_all(
3669 record.clone(),
3670 &compiled(&[RecordTransform::Redact {
3671 fields: vec!["a".into()],
3672 mask: json!("***"),
3673 }]),
3674 );
3675 assert_eq!(result, record);
3676 }
3677
3678 #[cfg(feature = "transform-value-case")]
3679 #[test]
3680 fn value_case_passes_through_non_object() {
3681 let record = json!([1, 2]);
3682 let result = apply_all(
3683 record.clone(),
3684 &compiled(&[RecordTransform::ValueCase {
3685 fields: vec!["a".into()],
3686 mode: ValueCaseMode::Lower,
3687 }]),
3688 );
3689 assert_eq!(result, record);
3690 }
3691
3692 #[cfg(feature = "transform-cast")]
3695 #[test]
3696 fn cast_integer_number_to_int_is_identity() {
3697 let record = json!({"n": 7});
3699 let result = apply_all(
3700 record,
3701 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3702 );
3703 assert_eq!(result["n"], 7);
3704 }
3705
3706 #[cfg(feature = "transform-cast")]
3707 #[test]
3708 fn cast_bool_to_int() {
3709 let record = json!({"t": true, "f": false});
3710 let mut fields = HashMap::new();
3711 fields.insert("t".to_owned(), CastType::Int);
3712 fields.insert("f".to_owned(), CastType::Int);
3713 let result = apply_all(
3714 record,
3715 &compiled(&[RecordTransform::Cast {
3716 fields,
3717 on_error: CastOnError::Error,
3718 }]),
3719 );
3720 assert_eq!(result["t"], 1);
3721 assert_eq!(result["f"], 0);
3722 }
3723
3724 #[cfg(feature = "transform-cast")]
3725 #[test]
3726 fn cast_null_to_int_errors() {
3727 let record = json!({"n": null});
3728 let err = super::apply_all(
3729 record,
3730 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3731 )
3732 .expect_err("null cannot become int");
3733 assert!(
3734 format!("{err}").contains("null cannot be cast to int"),
3735 "{err}"
3736 );
3737 }
3738
3739 #[cfg(feature = "transform-cast")]
3740 #[test]
3741 fn cast_composite_to_int_errors() {
3742 let record = json!({"n": [1, 2]});
3743 let err = super::apply_all(
3744 record,
3745 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3746 )
3747 .expect_err("array cannot become int");
3748 assert!(format!("{err}").contains("composite"), "{err}");
3749 }
3750
3751 #[cfg(feature = "transform-cast")]
3752 #[test]
3753 fn cast_number_to_float() {
3754 let record = json!({"n": 5});
3755 let result = apply_all(
3756 record,
3757 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3758 );
3759 assert_eq!(result["n"], 5.0);
3760 }
3761
3762 #[cfg(feature = "transform-cast")]
3763 #[test]
3764 fn cast_bool_to_float() {
3765 let record = json!({"t": true, "f": false});
3766 let mut fields = HashMap::new();
3767 fields.insert("t".to_owned(), CastType::Float);
3768 fields.insert("f".to_owned(), CastType::Float);
3769 let result = apply_all(
3770 record,
3771 &compiled(&[RecordTransform::Cast {
3772 fields,
3773 on_error: CastOnError::Error,
3774 }]),
3775 );
3776 assert_eq!(result["t"], 1.0);
3777 assert_eq!(result["f"], 0.0);
3778 }
3779
3780 #[cfg(feature = "transform-cast")]
3781 #[test]
3782 fn cast_null_to_float_errors() {
3783 let record = json!({"n": null});
3784 let err = super::apply_all(
3785 record,
3786 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3787 )
3788 .expect_err("null cannot become float");
3789 assert!(
3790 format!("{err}").contains("null cannot be cast to float"),
3791 "{err}"
3792 );
3793 }
3794
3795 #[cfg(feature = "transform-cast")]
3796 #[test]
3797 fn cast_composite_to_float_errors() {
3798 let record = json!({"n": {"x": 1}});
3799 let err = super::apply_all(
3800 record,
3801 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3802 )
3803 .expect_err("object cannot become float");
3804 assert!(format!("{err}").contains("composite"), "{err}");
3805 }
3806
3807 #[cfg(feature = "transform-cast")]
3808 #[test]
3809 fn cast_string_to_float_invalid_errors() {
3810 let record = json!({"n": "not a float"});
3811 let err = super::apply_all(
3812 record,
3813 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3814 )
3815 .expect_err("non-numeric string cannot become float");
3816 assert!(format!("{err}").contains("is not a float"), "{err}");
3817 }
3818
3819 #[cfg(feature = "transform-cast")]
3820 #[test]
3821 fn cast_bool_to_bool_is_identity() {
3822 let record = json!({"b": true});
3823 let result = apply_all(
3824 record,
3825 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
3826 );
3827 assert_eq!(result["b"], true);
3828 }
3829
3830 #[cfg(feature = "transform-cast")]
3831 #[test]
3832 fn cast_number_to_bool() {
3833 let record = json!({"on": 1, "off": 0});
3834 let mut fields = HashMap::new();
3835 fields.insert("on".to_owned(), CastType::Bool);
3836 fields.insert("off".to_owned(), CastType::Bool);
3837 let result = apply_all(
3838 record,
3839 &compiled(&[RecordTransform::Cast {
3840 fields,
3841 on_error: CastOnError::Error,
3842 }]),
3843 );
3844 assert_eq!(result["on"], true);
3845 assert_eq!(result["off"], false);
3846 }
3847
3848 #[cfg(feature = "transform-cast")]
3849 #[test]
3850 fn cast_integer_other_than_zero_one_to_bool_errors() {
3851 let record = json!({"n": 7});
3852 let err = super::apply_all(
3853 record,
3854 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
3855 )
3856 .expect_err("only 0/1 convert to bool");
3857 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
3858 }
3859
3860 #[cfg(feature = "transform-cast")]
3861 #[test]
3862 fn cast_float_number_to_bool_errors() {
3863 let record = json!({"n": 1.5});
3865 let err = super::apply_all(
3866 record,
3867 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
3868 )
3869 .expect_err("fractional number cannot become bool");
3870 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
3871 }
3872
3873 #[cfg(feature = "transform-cast")]
3874 #[test]
3875 fn cast_unrecognised_string_to_bool_errors() {
3876 let record = json!({"flag": "maybe"});
3877 let err = super::apply_all(
3878 record,
3879 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3880 )
3881 .expect_err("'maybe' is not a boolean");
3882 assert!(
3883 format!("{err}").contains("not a recognised boolean"),
3884 "{err}"
3885 );
3886 }
3887
3888 #[cfg(feature = "transform-cast")]
3889 #[test]
3890 fn cast_null_to_bool_errors() {
3891 let record = json!({"b": null});
3892 let err = super::apply_all(
3893 record,
3894 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
3895 )
3896 .expect_err("null cannot become bool");
3897 assert!(
3898 format!("{err}").contains("null cannot be cast to bool"),
3899 "{err}"
3900 );
3901 }
3902
3903 #[cfg(feature = "transform-cast")]
3904 #[test]
3905 fn cast_composite_to_bool_errors() {
3906 let record = json!({"b": [true]});
3907 let err = super::apply_all(
3908 record,
3909 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
3910 )
3911 .expect_err("array cannot become bool");
3912 assert!(format!("{err}").contains("composite"), "{err}");
3913 }
3914
3915 #[cfg(feature = "transform-cast")]
3916 #[test]
3917 fn cast_string_to_string_is_identity() {
3918 let record = json!({"s": "hello"});
3919 let result = apply_all(
3920 record,
3921 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
3922 );
3923 assert_eq!(result["s"], "hello");
3924 }
3925
3926 #[cfg(feature = "transform-cast")]
3927 #[test]
3928 fn cast_bool_to_string() {
3929 let record = json!({"b": true});
3930 let result = apply_all(
3931 record,
3932 &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
3933 );
3934 assert_eq!(result["b"], "true");
3935 }
3936
3937 #[cfg(feature = "transform-cast")]
3938 #[test]
3939 fn cast_null_to_string_errors() {
3940 let record = json!({"s": null});
3941 let err = super::apply_all(
3942 record,
3943 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
3944 )
3945 .expect_err("null cannot become string");
3946 assert!(
3947 format!("{err}").contains("null cannot be cast to string"),
3948 "{err}"
3949 );
3950 }
3951
3952 #[cfg(feature = "transform-cast")]
3953 #[test]
3954 fn cast_composite_to_string_errors() {
3955 let record = json!({"s": {"a": 1}});
3956 let err = super::apply_all(
3957 record,
3958 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
3959 )
3960 .expect_err("object cannot become string");
3961 assert!(format!("{err}").contains("composite"), "{err}");
3962 }
3963
3964 #[cfg(feature = "transform-cast")]
3965 #[test]
3966 fn cast_invalid_timestamp_string_errors() {
3967 let record = json!({"ts": "not a date"});
3968 let err = super::apply_all(
3969 record,
3970 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3971 )
3972 .expect_err("invalid timestamp string");
3973 assert!(format!("{err}").contains("RFC 3339"), "{err}");
3974 }
3975
3976 #[cfg(feature = "transform-cast")]
3977 #[test]
3978 fn cast_non_string_to_timestamp_names_the_type() {
3979 for (val, ty_name) in [
3981 (json!(null), "null"),
3982 (json!(true), "bool"),
3983 (json!(42), "number"),
3984 (json!([1, 2]), "array"),
3985 (json!({"a": 1}), "object"),
3986 ] {
3987 let record = json!({ "ts": val });
3988 let err = super::apply_all(
3989 record,
3990 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3991 )
3992 .expect_err("non-string cannot become timestamp");
3993 let msg = format!("{err}");
3994 assert!(msg.contains("timestamp"), "{msg}");
3995 assert!(
3996 msg.contains(ty_name),
3997 "expected type name {ty_name:?} in: {msg}"
3998 );
3999 }
4000 }
4001
4002 #[cfg(feature = "transform-hash")]
4005 fn hash_spec(fields: &[&str], enc: HashEncoding, salt: Option<&str>) -> Vec<RecordTransform> {
4006 vec![RecordTransform::Hash {
4007 fields: fields.iter().map(|s| (*s).to_owned()).collect(),
4008 algorithm: HashAlgorithm::Sha256,
4009 encoding: enc,
4010 salt: salt.map(str::to_owned),
4011 into: None,
4012 }]
4013 }
4014
4015 #[cfg(feature = "transform-hash")]
4016 #[test]
4017 fn hash_replaces_in_place_and_is_stable() {
4018 let a = apply_all(
4019 json!({"email": "a@b.com", "id": 1}),
4020 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4021 );
4022 let b = apply_all(
4023 json!({"email": "a@b.com", "id": 1}),
4024 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4025 );
4026 assert_eq!(a["email"], b["email"]);
4028 assert_eq!(a["id"], 1);
4029 assert_eq!(a["email"].as_str().unwrap().len(), 64);
4031 assert_ne!(a["email"], json!("a@b.com"));
4032 }
4033
4034 #[cfg(feature = "transform-hash")]
4035 #[test]
4036 fn hash_salt_changes_output() {
4037 let unsalted = apply_all(
4038 json!({"email": "a@b.com"}),
4039 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4040 );
4041 let salted = apply_all(
4042 json!({"email": "a@b.com"}),
4043 &compiled(&hash_spec(&["email"], HashEncoding::Hex, Some("pepper"))),
4044 );
4045 assert_ne!(unsalted["email"], salted["email"]);
4046 }
4047
4048 #[cfg(feature = "transform-hash")]
4049 #[test]
4050 fn hash_hex_vs_base64_differ_and_both_decode() {
4051 let hex = apply_all(
4052 json!({"v": "x"}),
4053 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4054 );
4055 let b64 = apply_all(
4056 json!({"v": "x"}),
4057 &compiled(&hash_spec(&["v"], HashEncoding::Base64, None)),
4058 );
4059 assert_ne!(hex["v"], b64["v"]);
4060 assert_eq!(hex["v"].as_str().unwrap().len(), 64);
4062 assert_eq!(b64["v"].as_str().unwrap().len(), 44);
4063 }
4064
4065 #[cfg(feature = "transform-hash")]
4066 #[test]
4067 fn hash_into_preserves_source() {
4068 let out = apply_all(
4069 json!({"email": "a@b.com"}),
4070 &compiled(&[RecordTransform::Hash {
4071 fields: vec!["email".into()],
4072 algorithm: HashAlgorithm::Sha256,
4073 encoding: HashEncoding::Hex,
4074 salt: None,
4075 into: Some("email_hash".into()),
4076 }]),
4077 );
4078 assert_eq!(out["email"], "a@b.com");
4079 assert_eq!(out["email_hash"].as_str().unwrap().len(), 64);
4080 }
4081
4082 #[cfg(feature = "transform-hash")]
4083 #[test]
4084 fn hash_missing_field_is_no_op() {
4085 let out = apply_all(
4086 json!({"id": 1}),
4087 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4088 );
4089 assert_eq!(out, json!({"id": 1}));
4090 }
4091
4092 #[cfg(feature = "transform-hash")]
4097 #[test]
4098 fn hash_leaves_null_alone() {
4099 let out = apply_all(
4100 json!({"email": null, "other": "x"}),
4101 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4102 );
4103 assert_eq!(out["email"], Value::Null, "null must not be hashed");
4104 assert_eq!(out["other"], json!("x"));
4105
4106 let a = apply_all(
4109 json!({"id": 1, "email": null}),
4110 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4111 );
4112 let b = apply_all(
4113 json!({"id": 2, "email": null}),
4114 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4115 );
4116 assert_ne!(a, b);
4117 assert!(a["email"].is_null() && b["email"].is_null());
4118 }
4119
4120 #[cfg(feature = "transform-hash")]
4121 #[test]
4122 fn hash_non_string_hashes_canonical_json() {
4123 let out = apply_all(
4125 json!({"n": 42}),
4126 &compiled(&hash_spec(&["n"], HashEncoding::Hex, None)),
4127 );
4128 let expected = hash_string("42", HashAlgorithm::Sha256, HashEncoding::Hex, None);
4129 assert_eq!(out["n"], Value::String(expected));
4130 }
4131
4132 #[cfg(feature = "transform-hash")]
4133 #[test]
4134 fn hash_blake3_differs_from_sha256() {
4135 let sha = apply_all(
4136 json!({"v": "x"}),
4137 &compiled(&[RecordTransform::Hash {
4138 fields: vec!["v".into()],
4139 algorithm: HashAlgorithm::Sha256,
4140 encoding: HashEncoding::Hex,
4141 salt: None,
4142 into: None,
4143 }]),
4144 );
4145 let b3 = apply_all(
4146 json!({"v": "x"}),
4147 &compiled(&[RecordTransform::Hash {
4148 fields: vec!["v".into()],
4149 algorithm: HashAlgorithm::Blake3,
4150 encoding: HashEncoding::Hex,
4151 salt: None,
4152 into: None,
4153 }]),
4154 );
4155 assert_ne!(sha["v"], b3["v"]);
4156 assert_eq!(b3["v"].as_str().unwrap().len(), 64);
4157 }
4158
4159 #[cfg(feature = "transform-hash")]
4160 #[test]
4161 fn hash_empty_fields_is_config_error() {
4162 let res = compile(&RecordTransform::Hash {
4163 fields: vec![],
4164 algorithm: HashAlgorithm::Sha256,
4165 encoding: HashEncoding::Hex,
4166 salt: None,
4167 into: None,
4168 });
4169 assert!(matches!(res, Err(FaucetError::Config(_))));
4170 }
4171
4172 #[cfg(feature = "transform-hash")]
4173 #[test]
4174 fn hash_into_with_multiple_fields_is_config_error() {
4175 let res = compile(&RecordTransform::Hash {
4176 fields: vec!["a".into(), "b".into()],
4177 algorithm: HashAlgorithm::Sha256,
4178 encoding: HashEncoding::Hex,
4179 salt: None,
4180 into: Some("x".into()),
4181 });
4182 assert!(matches!(res, Err(FaucetError::Config(_))));
4183 }
4184
4185 #[cfg(feature = "transform-hash")]
4186 #[test]
4187 fn hash_debug_redacts_salt() {
4188 let dbg = format!(
4189 "{:?}",
4190 RecordTransform::Hash {
4191 fields: vec!["a".into()],
4192 algorithm: HashAlgorithm::Sha256,
4193 encoding: HashEncoding::Hex,
4194 salt: Some("supersecret".into()),
4195 into: None,
4196 }
4197 );
4198 assert!(!dbg.contains("supersecret"), "{dbg}");
4199 assert!(dbg.contains("redacted"), "{dbg}");
4200 }
4201
4202 #[cfg(feature = "transform-json-parse")]
4205 fn json_parse_spec(field: &str, on_error: JsonParseOnError) -> Vec<RecordTransform> {
4206 vec![RecordTransform::JsonParse {
4207 fields: vec![field.to_owned()],
4208 on_error,
4209 into: None,
4210 }]
4211 }
4212
4213 #[cfg(feature = "transform-json-parse")]
4214 #[test]
4215 fn json_parse_object_string_becomes_object() {
4216 let out = apply_all(
4217 json!({"payload": "{\"a\":1,\"b\":[2,3]}"}),
4218 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4219 );
4220 assert_eq!(out["payload"], json!({"a": 1, "b": [2, 3]}));
4221 }
4222
4223 #[cfg(feature = "transform-json-parse")]
4224 #[test]
4225 fn json_parse_already_parsed_is_no_op() {
4226 let record = json!({"payload": {"a": 1}});
4227 let out = apply_all(
4228 record.clone(),
4229 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4230 );
4231 assert_eq!(out, record);
4232 }
4233
4234 #[cfg(feature = "transform-json-parse")]
4235 #[test]
4236 fn json_parse_missing_field_is_no_op() {
4237 let out = apply_all(
4238 json!({"id": 1}),
4239 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4240 );
4241 assert_eq!(out, json!({"id": 1}));
4242 }
4243
4244 #[cfg(feature = "transform-json-parse")]
4245 #[test]
4246 fn json_parse_invalid_keep_leaves_string() {
4247 let out = apply_all(
4248 json!({"payload": "not json"}),
4249 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4250 );
4251 assert_eq!(out["payload"], "not json");
4252 }
4253
4254 #[cfg(feature = "transform-json-parse")]
4255 #[test]
4256 fn json_parse_invalid_null_replaces() {
4257 let out = apply_all(
4258 json!({"payload": "not json"}),
4259 &compiled(&json_parse_spec("payload", JsonParseOnError::Null)),
4260 );
4261 assert_eq!(out["payload"], Value::Null);
4262 }
4263
4264 #[cfg(feature = "transform-json-parse")]
4265 #[test]
4266 fn json_parse_invalid_error_propagates() {
4267 let err = super::apply_all(
4268 json!({"payload": "not json"}),
4269 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4270 )
4271 .expect_err("invalid JSON under on_error=error must fail");
4272 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
4273 }
4274
4275 #[cfg(feature = "transform-json-parse")]
4276 #[test]
4277 fn json_parse_into_writes_target() {
4278 let out = apply_all(
4279 json!({"payload": "{\"a\":1}"}),
4280 &compiled(&[RecordTransform::JsonParse {
4281 fields: vec!["payload".into()],
4282 on_error: JsonParseOnError::Error,
4283 into: Some("parsed".into()),
4284 }]),
4285 );
4286 assert_eq!(out["payload"], "{\"a\":1}");
4287 assert_eq!(out["parsed"], json!({"a": 1}));
4288 }
4289
4290 #[cfg(feature = "transform-coalesce")]
4293 #[test]
4294 fn coalesce_default_fills_null_and_absent() {
4295 let spec = |field: &str| {
4296 vec![RecordTransform::Coalesce {
4297 field: field.to_owned(),
4298 default: Some(json!("unknown")),
4299 from: vec![],
4300 treat_empty_string_as_null: false,
4301 }]
4302 };
4303 let a = apply_all(json!({"status": null}), &compiled(&spec("status")));
4305 assert_eq!(a["status"], "unknown");
4306 let b = apply_all(json!({"id": 1}), &compiled(&spec("status")));
4308 assert_eq!(b["status"], "unknown");
4309 }
4310
4311 #[cfg(feature = "transform-coalesce")]
4312 #[test]
4313 fn coalesce_non_null_target_untouched() {
4314 let out = apply_all(
4315 json!({"status": "active"}),
4316 &compiled(&[RecordTransform::Coalesce {
4317 field: "status".into(),
4318 default: Some(json!("unknown")),
4319 from: vec![],
4320 treat_empty_string_as_null: false,
4321 }]),
4322 );
4323 assert_eq!(out["status"], "active");
4324 }
4325
4326 #[cfg(feature = "transform-coalesce")]
4327 #[test]
4328 fn coalesce_from_picks_first_non_null() {
4329 let out = apply_all(
4330 json!({"status": null, "state": null, "phase": "running"}),
4331 &compiled(&[RecordTransform::Coalesce {
4332 field: "status".into(),
4333 default: None,
4334 from: vec!["status".into(), "state".into(), "phase".into()],
4335 treat_empty_string_as_null: false,
4336 }]),
4337 );
4338 assert_eq!(out["status"], "running");
4339 }
4340
4341 #[cfg(feature = "transform-coalesce")]
4342 #[test]
4343 fn coalesce_empty_string_toggle() {
4344 let spec = |treat: bool| {
4345 vec![RecordTransform::Coalesce {
4346 field: "status".into(),
4347 default: Some(json!("unknown")),
4348 from: vec![],
4349 treat_empty_string_as_null: treat,
4350 }]
4351 };
4352 let off = apply_all(json!({"status": ""}), &compiled(&spec(false)));
4354 assert_eq!(off["status"], "");
4355 let on = apply_all(json!({"status": ""}), &compiled(&spec(true)));
4357 assert_eq!(on["status"], "unknown");
4358 }
4359
4360 #[cfg(feature = "transform-coalesce")]
4361 #[test]
4362 fn coalesce_from_all_null_leaves_target() {
4363 let out = apply_all(
4364 json!({"status": null, "state": null}),
4365 &compiled(&[RecordTransform::Coalesce {
4366 field: "status".into(),
4367 default: None,
4368 from: vec!["status".into(), "state".into()],
4369 treat_empty_string_as_null: false,
4370 }]),
4371 );
4372 assert_eq!(out["status"], Value::Null);
4373 }
4374
4375 #[cfg(feature = "transform-coalesce")]
4376 #[test]
4377 fn coalesce_both_default_and_from_is_config_error() {
4378 let res = compile(&RecordTransform::Coalesce {
4379 field: "status".into(),
4380 default: Some(json!("x")),
4381 from: vec!["state".into()],
4382 treat_empty_string_as_null: false,
4383 });
4384 assert!(matches!(res, Err(FaucetError::Config(_))));
4385 }
4386
4387 #[cfg(feature = "transform-coalesce")]
4388 #[test]
4389 fn coalesce_neither_default_nor_from_is_config_error() {
4390 let res = compile(&RecordTransform::Coalesce {
4391 field: "status".into(),
4392 default: None,
4393 from: vec![],
4394 treat_empty_string_as_null: false,
4395 });
4396 assert!(matches!(res, Err(FaucetError::Config(_))));
4397 }
4398
4399 #[cfg(feature = "transform-split-join")]
4402 #[test]
4403 fn split_basic_no_trim() {
4404 let out = apply_all(
4405 json!({"tags": "a, b ,c"}),
4406 &compiled(&[RecordTransform::Split {
4407 field: "tags".into(),
4408 delimiter: ",".into(),
4409 trim: false,
4410 into: None,
4411 }]),
4412 );
4413 assert_eq!(out["tags"], json!(["a", " b ", "c"]));
4414 }
4415
4416 #[cfg(feature = "transform-split-join")]
4417 #[test]
4418 fn split_with_trim_keeps_empty_segments() {
4419 let out = apply_all(
4420 json!({"tags": "a, ,c,"}),
4421 &compiled(&[RecordTransform::Split {
4422 field: "tags".into(),
4423 delimiter: ",".into(),
4424 trim: true,
4425 into: None,
4426 }]),
4427 );
4428 assert_eq!(out["tags"], json!(["a", "", "c", ""]));
4430 }
4431
4432 #[cfg(feature = "transform-split-join")]
4433 #[test]
4434 fn split_empty_input_yields_single_empty() {
4435 let out = apply_all(
4436 json!({"tags": ""}),
4437 &compiled(&[RecordTransform::Split {
4438 field: "tags".into(),
4439 delimiter: ",".into(),
4440 trim: false,
4441 into: None,
4442 }]),
4443 );
4444 assert_eq!(out["tags"], json!([""]));
4445 }
4446
4447 #[cfg(feature = "transform-split-join")]
4448 #[test]
4449 fn split_non_string_is_no_op() {
4450 let record = json!({"tags": [1, 2]});
4451 let out = apply_all(
4452 record.clone(),
4453 &compiled(&[RecordTransform::Split {
4454 field: "tags".into(),
4455 delimiter: ",".into(),
4456 trim: false,
4457 into: None,
4458 }]),
4459 );
4460 assert_eq!(out, record);
4461 }
4462
4463 #[cfg(feature = "transform-split-join")]
4464 #[test]
4465 fn split_into_writes_target() {
4466 let out = apply_all(
4467 json!({"csv": "a,b"}),
4468 &compiled(&[RecordTransform::Split {
4469 field: "csv".into(),
4470 delimiter: ",".into(),
4471 trim: false,
4472 into: Some("arr".into()),
4473 }]),
4474 );
4475 assert_eq!(out["csv"], "a,b");
4476 assert_eq!(out["arr"], json!(["a", "b"]));
4477 }
4478
4479 #[cfg(feature = "transform-split-join")]
4480 #[test]
4481 fn join_basic_and_non_string_elements() {
4482 let out = apply_all(
4483 json!({"parts": ["a", 2, true, null]}),
4484 &compiled(&[RecordTransform::Join {
4485 field: "parts".into(),
4486 delimiter: ",".into(),
4487 into: None,
4488 }]),
4489 );
4490 assert_eq!(out["parts"], "a,2,true,");
4492 }
4493
4494 #[cfg(feature = "transform-split-join")]
4495 #[test]
4496 fn join_non_array_is_no_op() {
4497 let record = json!({"parts": "already a string"});
4498 let out = apply_all(
4499 record.clone(),
4500 &compiled(&[RecordTransform::Join {
4501 field: "parts".into(),
4502 delimiter: ",".into(),
4503 into: None,
4504 }]),
4505 );
4506 assert_eq!(out, record);
4507 }
4508
4509 #[cfg(feature = "transform-split-join")]
4510 #[test]
4511 fn split_then_join_round_trips() {
4512 let out = apply_all(
4513 json!({"tags": "a,b,c"}),
4514 &compiled(&[
4515 RecordTransform::Split {
4516 field: "tags".into(),
4517 delimiter: ",".into(),
4518 trim: false,
4519 into: None,
4520 },
4521 RecordTransform::Join {
4522 field: "tags".into(),
4523 delimiter: ",".into(),
4524 into: None,
4525 },
4526 ]),
4527 );
4528 assert_eq!(out["tags"], "a,b,c");
4529 }
4530
4531 #[cfg(feature = "transform-value-case")]
4534 #[test]
4535 fn value_case_title() {
4536 let out = apply_all(
4537 json!({"city": "new york", "id": 1}),
4538 &compiled(&[RecordTransform::ValueCase {
4539 fields: vec!["city".into()],
4540 mode: ValueCaseMode::Title,
4541 }]),
4542 );
4543 assert_eq!(out["city"], "New York");
4544 assert_eq!(out["id"], 1);
4545 }
4546
4547 #[cfg(feature = "transform-value-case")]
4548 #[test]
4549 fn value_case_title_lowercases_rest_of_word() {
4550 let out = apply_all(
4551 json!({"s": "hELLO WORLD"}),
4552 &compiled(&[RecordTransform::ValueCase {
4553 fields: vec!["s".into()],
4554 mode: ValueCaseMode::Title,
4555 }]),
4556 );
4557 assert_eq!(out["s"], "Hello World");
4558 }
4559
4560 #[cfg(feature = "transform-value-case")]
4561 #[test]
4562 fn value_case_capitalize() {
4563 let out = apply_all(
4564 json!({"s": "hELLO wORLD"}),
4565 &compiled(&[RecordTransform::ValueCase {
4566 fields: vec!["s".into()],
4567 mode: ValueCaseMode::Capitalize,
4568 }]),
4569 );
4570 assert_eq!(out["s"], "Hello world");
4571 }
4572
4573 #[cfg(feature = "transform-value-case")]
4574 #[test]
4575 fn value_case_title_is_idempotent() {
4576 let once = apply_all(
4577 json!({"s": "new york"}),
4578 &compiled(&[RecordTransform::ValueCase {
4579 fields: vec!["s".into()],
4580 mode: ValueCaseMode::Title,
4581 }]),
4582 );
4583 let twice = apply_all(
4584 once.clone(),
4585 &compiled(&[RecordTransform::ValueCase {
4586 fields: vec!["s".into()],
4587 mode: ValueCaseMode::Title,
4588 }]),
4589 );
4590 assert_eq!(once, twice);
4591 }
4592
4593 #[cfg(feature = "transform-value-case")]
4594 #[test]
4595 fn value_case_title_non_string_no_op() {
4596 let out = apply_all(
4597 json!({"n": 42}),
4598 &compiled(&[RecordTransform::ValueCase {
4599 fields: vec!["n".into()],
4600 mode: ValueCaseMode::Title,
4601 }]),
4602 );
4603 assert_eq!(out["n"], 42);
4604 }
4605
4606 #[cfg(feature = "transform-keys-case")]
4609 #[test]
4610 fn keys_case_dot() {
4611 let out = apply_all(
4612 json!({"userId": 1, "First Name": 2, "kebab-case": 3}),
4613 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4614 );
4615 assert_eq!(out["user.id"], 1);
4616 assert_eq!(out["first.name"], 2);
4617 assert_eq!(out["kebab.case"], 3);
4618 }
4619
4620 #[cfg(feature = "transform-keys-case")]
4621 #[test]
4622 fn keys_case_dot_is_idempotent() {
4623 let once = apply_all(
4624 json!({"userId": 1}),
4625 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4626 );
4627 let twice = apply_all(once.clone(), &compiled(&keys_case_specs(KeyCaseMode::Dot)));
4628 assert_eq!(once, twice);
4629 assert_eq!(twice["user.id"], 1);
4630 }
4631
4632 #[cfg(feature = "transform-keys-case")]
4633 #[test]
4634 fn keys_case_dot_matches_snake_tokenization() {
4635 let record = json!({"XMLHttpRequest": 1, "second name": 2});
4637 let dot = apply_all(
4638 record.clone(),
4639 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4640 );
4641 let snake = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
4642 let dot_keys: Vec<String> = dot.as_object().unwrap().keys().cloned().collect();
4644 let snake_keys: Vec<String> = snake.as_object().unwrap().keys().cloned().collect();
4645 let converted: Vec<String> = snake_keys.iter().map(|k| k.replace('_', ".")).collect();
4646 assert_eq!(dot_keys, converted);
4647 }
4648
4649 #[cfg(feature = "transform-json-parse")]
4652 #[test]
4653 fn json_parse_empty_fields_is_config_error() {
4654 let res = compile(&RecordTransform::JsonParse {
4655 fields: vec![],
4656 on_error: JsonParseOnError::Keep,
4657 into: None,
4658 });
4659 assert!(matches!(res, Err(FaucetError::Config(_))));
4660 }
4661
4662 #[cfg(feature = "transform-json-parse")]
4663 #[test]
4664 fn json_parse_into_with_multiple_fields_is_config_error() {
4665 let res = compile(&RecordTransform::JsonParse {
4666 fields: vec!["a".into(), "b".into()],
4667 on_error: JsonParseOnError::Keep,
4668 into: Some("x".into()),
4669 });
4670 assert!(matches!(res, Err(FaucetError::Config(_))));
4671 }
4672
4673 #[cfg(feature = "transform-hash")]
4674 #[test]
4675 fn hash_passes_through_non_object() {
4676 let record = json!([1, 2, 3]);
4677 let result = apply_all(
4678 record.clone(),
4679 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4680 );
4681 assert_eq!(result, record);
4682 }
4683
4684 #[cfg(feature = "transform-json-parse")]
4685 #[test]
4686 fn json_parse_passes_through_non_object() {
4687 let record = json!("scalar");
4688 let result = apply_all(
4689 record.clone(),
4690 &compiled(&json_parse_spec("v", JsonParseOnError::Error)),
4691 );
4692 assert_eq!(result, record);
4693 }
4694
4695 #[cfg(feature = "transform-coalesce")]
4696 #[test]
4697 fn coalesce_non_null_non_string_target_untouched() {
4698 let out = apply_all(
4701 json!({"n": 0}),
4702 &compiled(&[RecordTransform::Coalesce {
4703 field: "n".into(),
4704 default: Some(json!(99)),
4705 from: vec![],
4706 treat_empty_string_as_null: true,
4707 }]),
4708 );
4709 assert_eq!(out["n"], 0);
4710 }
4711
4712 #[cfg(feature = "transform-coalesce")]
4713 #[test]
4714 fn coalesce_passes_through_non_object() {
4715 let record = json!([1, 2]);
4716 let result = apply_all(
4717 record.clone(),
4718 &compiled(&[RecordTransform::Coalesce {
4719 field: "a".into(),
4720 default: Some(json!("x")),
4721 from: vec![],
4722 treat_empty_string_as_null: false,
4723 }]),
4724 );
4725 assert_eq!(result, record);
4726 }
4727
4728 #[cfg(feature = "transform-split-join")]
4729 #[test]
4730 fn split_and_join_pass_through_non_object() {
4731 let record = json!(42);
4732 let split = apply_all(
4733 record.clone(),
4734 &compiled(&[RecordTransform::Split {
4735 field: "a".into(),
4736 delimiter: ",".into(),
4737 trim: false,
4738 into: None,
4739 }]),
4740 );
4741 assert_eq!(split, record);
4742 let join = apply_all(
4743 record.clone(),
4744 &compiled(&[RecordTransform::Join {
4745 field: "a".into(),
4746 delimiter: ",".into(),
4747 into: None,
4748 }]),
4749 );
4750 assert_eq!(join, record);
4751 }
4752
4753 #[cfg(feature = "transform-split-join")]
4754 #[test]
4755 fn split_empty_delimiter_yields_single_element() {
4756 let out = apply_all(
4757 json!({"s": " hi "}),
4758 &compiled(&[RecordTransform::Split {
4759 field: "s".into(),
4760 delimiter: String::new(),
4761 trim: true,
4762 into: None,
4763 }]),
4764 );
4765 assert_eq!(out["s"], json!(["hi"]));
4767 }
4768
4769 #[cfg(feature = "transform-value-case")]
4770 #[test]
4771 fn value_case_title_and_capitalize_handle_empty_string() {
4772 for mode in [ValueCaseMode::Title, ValueCaseMode::Capitalize] {
4773 let out = apply_all(
4774 json!({"s": ""}),
4775 &compiled(&[RecordTransform::ValueCase {
4776 fields: vec!["s".into()],
4777 mode,
4778 }]),
4779 );
4780 assert_eq!(out["s"], "");
4781 }
4782 }
4783}