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 let input = match current {
1941 Value::String(s) => s.clone(),
1942 other => other.to_string(),
1943 };
1944 let digest = hash_string(&input, algorithm, encoding, salt);
1945 let target = into.unwrap_or(field.as_str());
1946 map.insert(target.to_owned(), Value::String(digest));
1947 }
1948 Value::Object(map)
1949 }
1950 other => other,
1951 }
1952}
1953
1954#[cfg(feature = "transform-hash")]
1955fn hash_string(
1956 input: &str,
1957 algorithm: HashAlgorithm,
1958 encoding: HashEncoding,
1959 salt: Option<&str>,
1960) -> String {
1961 let mut bytes: Vec<u8> = Vec::with_capacity(salt.map_or(0, str::len) + input.len());
1963 if let Some(s) = salt {
1964 bytes.extend_from_slice(s.as_bytes());
1965 }
1966 bytes.extend_from_slice(input.as_bytes());
1967 let digest: Vec<u8> = match algorithm {
1968 HashAlgorithm::Sha256 => {
1969 use sha2::{Digest, Sha256};
1970 let mut h = Sha256::new();
1971 h.update(&bytes);
1972 h.finalize().to_vec()
1973 }
1974 HashAlgorithm::Blake3 => blake3::hash(&bytes).as_bytes().to_vec(),
1975 };
1976 match encoding {
1977 HashEncoding::Hex => hex_encode(&digest),
1978 HashEncoding::Base64 => {
1979 use base64::Engine;
1980 base64::engine::general_purpose::STANDARD.encode(&digest)
1981 }
1982 }
1983}
1984
1985#[cfg(feature = "transform-hash")]
1986fn hex_encode(bytes: &[u8]) -> String {
1987 const HEX: &[u8; 16] = b"0123456789abcdef";
1988 let mut s = String::with_capacity(bytes.len() * 2);
1989 for &b in bytes {
1990 s.push(HEX[(b >> 4) as usize] as char);
1991 s.push(HEX[(b & 0x0f) as usize] as char);
1992 }
1993 s
1994}
1995
1996#[cfg(feature = "transform-json-parse")]
1999fn json_parse_fields(
2000 value: Value,
2001 fields: &[String],
2002 on_error: JsonParseOnError,
2003 into: Option<&str>,
2004) -> Result<Value, FaucetError> {
2005 match value {
2006 Value::Object(mut map) => {
2007 for field in fields {
2008 let Some(Value::String(s)) = map.get(field) else {
2011 continue;
2012 };
2013 let s = s.clone();
2014 match serde_json::from_str::<Value>(&s) {
2015 Ok(parsed) => {
2016 let target = into.unwrap_or(field.as_str());
2017 map.insert(target.to_owned(), parsed);
2018 }
2019 Err(e) => match on_error {
2020 JsonParseOnError::Keep => { }
2021 JsonParseOnError::Null => {
2022 let target = into.unwrap_or(field.as_str());
2023 map.insert(target.to_owned(), Value::Null);
2024 }
2025 JsonParseOnError::Error => {
2026 return Err(FaucetError::Transform(format!(
2027 "json_parse: field '{field}' is not valid JSON: {e}"
2028 )));
2029 }
2030 },
2031 }
2032 }
2033 Ok(Value::Object(map))
2034 }
2035 other => Ok(other),
2036 }
2037}
2038
2039#[cfg(feature = "transform-coalesce")]
2042fn coalesce_field(
2043 value: Value,
2044 field: &str,
2045 default: Option<&Value>,
2046 from: &[String],
2047 treat_empty_string_as_null: bool,
2048) -> Value {
2049 match value {
2050 Value::Object(mut map) => {
2051 if is_nullish(map.get(field), treat_empty_string_as_null) {
2052 let replacement: Option<Value> = match default {
2053 Some(d) => Some(d.clone()),
2054 None => from.iter().find_map(|k| {
2055 let v = map.get(k);
2056 if is_nullish(v, treat_empty_string_as_null) {
2057 None
2058 } else {
2059 v.cloned()
2060 }
2061 }),
2062 };
2063 if let Some(v) = replacement {
2064 map.insert(field.to_owned(), v);
2065 }
2066 }
2067 Value::Object(map)
2068 }
2069 other => other,
2070 }
2071}
2072
2073#[cfg(feature = "transform-coalesce")]
2076fn is_nullish(v: Option<&Value>, treat_empty_string_as_null: bool) -> bool {
2077 match v {
2078 None | Some(Value::Null) => true,
2079 Some(Value::String(s)) => treat_empty_string_as_null && s.is_empty(),
2080 _ => false,
2081 }
2082}
2083
2084#[cfg(feature = "transform-split-join")]
2087fn split_field(
2088 value: Value,
2089 field: &str,
2090 delimiter: &str,
2091 trim: bool,
2092 into: Option<&str>,
2093) -> Value {
2094 match value {
2095 Value::Object(mut map) => {
2096 let Some(Value::String(s)) = map.get(field) else {
2097 return Value::Object(map);
2098 };
2099 let s = s.clone();
2100 let parts: Vec<Value> = if delimiter.is_empty() {
2104 vec![Value::String(if trim { s.trim().to_owned() } else { s })]
2105 } else {
2106 s.split(delimiter)
2107 .map(|part| {
2108 let p = if trim { part.trim() } else { part };
2109 Value::String(p.to_owned())
2110 })
2111 .collect()
2112 };
2113 let target = into.unwrap_or(field);
2114 map.insert(target.to_owned(), Value::Array(parts));
2115 Value::Object(map)
2116 }
2117 other => other,
2118 }
2119}
2120
2121#[cfg(feature = "transform-split-join")]
2122fn join_field(value: Value, field: &str, delimiter: &str, into: Option<&str>) -> Value {
2123 match value {
2124 Value::Object(mut map) => {
2125 let Some(Value::Array(arr)) = map.get(field) else {
2126 return Value::Object(map);
2127 };
2128 let joined = arr
2129 .iter()
2130 .map(scalar_to_string)
2131 .collect::<Vec<_>>()
2132 .join(delimiter);
2133 let target = into.unwrap_or(field);
2134 map.insert(target.to_owned(), Value::String(joined));
2135 Value::Object(map)
2136 }
2137 other => other,
2138 }
2139}
2140
2141#[cfg(feature = "transform-split-join")]
2144fn scalar_to_string(v: &Value) -> String {
2145 match v {
2146 Value::String(s) => s.clone(),
2147 Value::Null => String::new(),
2148 other => other.to_string(),
2149 }
2150}
2151
2152#[cfg(test)]
2155mod tests {
2156 use super::*;
2157 use serde_json::json;
2158
2159 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
2164 super::apply_all(record, transforms).expect("transform should succeed in this test")
2165 }
2166
2167 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
2168 transforms.iter().map(|t| compile(t).unwrap()).collect()
2169 }
2170
2171 #[test]
2174 fn test_custom_adds_field() {
2175 let record = json!({"id": 1});
2176 let result = apply_all(
2177 record,
2178 &compiled(&[RecordTransform::custom(|mut v| {
2179 if let Value::Object(ref mut m) = v {
2180 m.insert("added".to_string(), json!(true));
2181 }
2182 v
2183 })]),
2184 );
2185 assert_eq!(result["id"], 1);
2186 assert_eq!(result["added"], true);
2187 }
2188
2189 #[test]
2190 fn test_custom_removes_field() {
2191 let record = json!({"id": 1, "secret": "drop_me"});
2192 let result = apply_all(
2193 record,
2194 &compiled(&[RecordTransform::custom(|mut v| {
2195 if let Value::Object(ref mut m) = v {
2196 m.remove("secret");
2197 }
2198 v
2199 })]),
2200 );
2201 assert_eq!(result["id"], 1);
2202 assert!(result.get("secret").is_none());
2203 }
2204
2205 #[test]
2206 fn test_no_transforms_is_identity() {
2207 let record = json!({"id": 1, "name": "Alice"});
2208 let result = apply_all(record.clone(), &[]);
2209 assert_eq!(result, record);
2210 }
2211
2212 #[cfg(feature = "transform-flatten")]
2215 #[test]
2216 fn test_flatten_nested_object() {
2217 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
2218 let result = apply_all(
2219 record,
2220 &compiled(&[RecordTransform::Flatten {
2221 separator: "__".into(),
2222 }]),
2223 );
2224 assert_eq!(result["a__b"], 1);
2225 assert_eq!(result["a__c__d"], 2);
2226 assert_eq!(result["e"], 3);
2227 assert!(result.get("a").is_none(), "nested key should be removed");
2228 }
2229
2230 #[cfg(feature = "transform-flatten")]
2231 #[test]
2232 fn test_flatten_leaves_arrays_intact() {
2233 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
2234 let result = apply_all(
2235 record,
2236 &compiled(&[RecordTransform::Flatten {
2237 separator: ".".into(),
2238 }]),
2239 );
2240 assert_eq!(result["tags"], json!(["rust", "api"]));
2241 assert_eq!(result["meta.count"], 2);
2242 }
2243
2244 #[cfg(feature = "transform-flatten")]
2245 #[test]
2246 fn test_flatten_already_flat() {
2247 let record = json!({"id": 1, "name": "Alice"});
2248 let result = apply_all(
2249 record.clone(),
2250 &compiled(&[RecordTransform::Flatten {
2251 separator: "__".into(),
2252 }]),
2253 );
2254 assert_eq!(result, record);
2255 }
2256
2257 #[cfg(feature = "transform-flatten")]
2258 #[test]
2259 fn test_flatten_empty_separator() {
2260 let record = json!({"a": {"b": 1}});
2261 let result = apply_all(
2262 record,
2263 &compiled(&[RecordTransform::Flatten {
2264 separator: "".into(),
2265 }]),
2266 );
2267 assert_eq!(result["ab"], 1);
2268 }
2269
2270 #[cfg(feature = "transform-rename-keys")]
2273 #[test]
2274 fn test_rename_keys_strips_prefix() {
2275 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
2276 let result = apply_all(
2277 record,
2278 &compiled(&[RecordTransform::RenameKeys {
2279 pattern: r"^_prefix_".into(),
2280 replacement: "".into(),
2281 }]),
2282 );
2283 assert_eq!(result["id"], 1);
2284 assert_eq!(result["name"], "Alice");
2285 }
2286
2287 #[cfg(feature = "transform-rename-keys")]
2288 #[test]
2289 fn test_rename_keys_uppercase_to_placeholder() {
2290 let record = json!({"OUTER": {"INNER": 42}});
2291 let result = apply_all(
2292 record,
2293 &compiled(&[RecordTransform::RenameKeys {
2294 pattern: r"[A-Z]+".into(),
2295 replacement: "x".into(),
2296 }]),
2297 );
2298 assert_eq!(result["x"]["x"], 42);
2299 }
2300
2301 #[cfg(feature = "transform-rename-keys")]
2302 #[test]
2303 fn test_rename_keys_in_array_elements() {
2304 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
2305 let result = apply_all(
2306 record,
2307 &compiled(&[RecordTransform::RenameKeys {
2308 pattern: r"KEY".into(),
2309 replacement: "key".into(),
2310 }]),
2311 );
2312 assert_eq!(result["items"][0]["key"], 1);
2313 assert_eq!(result["items"][1]["key"], 2);
2314 }
2315
2316 #[cfg(feature = "transform-rename-keys")]
2317 #[test]
2318 fn test_rename_keys_invalid_regex_errors_at_compile() {
2319 let err = compile(&RecordTransform::RenameKeys {
2320 pattern: "[invalid".into(),
2321 replacement: "".into(),
2322 });
2323 assert!(err.is_err());
2324 assert!(matches!(err, Err(FaucetError::Transform(_))));
2325 }
2326
2327 #[cfg(feature = "transform-rename-keys")]
2328 #[test]
2329 fn test_rename_keys_chained() {
2330 let record = json!({"__camelCase__": 1});
2331 let result = apply_all(
2332 record,
2333 &compiled(&[
2334 RecordTransform::RenameKeys {
2335 pattern: r"^_+|_+$".into(),
2336 replacement: "".into(),
2337 },
2338 RecordTransform::RenameKeys {
2339 pattern: r"[A-Z]".into(),
2340 replacement: "_".into(),
2341 },
2342 ]),
2343 );
2344 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2345 assert_eq!(key, "camel_ase");
2346 }
2347
2348 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
2351 #[test]
2352 fn test_keys_case_then_flatten() {
2353 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
2354 let result = apply_all(
2355 record,
2356 &compiled(&[
2357 RecordTransform::KeysCase {
2358 mode: KeyCaseMode::Snake,
2359 },
2360 RecordTransform::Flatten {
2361 separator: "_".into(),
2362 },
2363 ]),
2364 );
2365 assert_eq!(result["user_info_first_name"], "Alice");
2366 assert_eq!(result["user_info_last_name"], "Smith");
2367 }
2368
2369 #[test]
2370 fn test_custom_chained_with_builtin() {
2371 let record = json!({"id": 1, "raw_value": 100});
2373 let result = apply_all(
2374 record,
2375 &compiled(&[
2376 RecordTransform::custom(|mut v| {
2378 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
2379 && let Value::Object(ref mut m) = v
2380 {
2381 m.insert("raw_value".to_string(), json!(n * 2));
2382 }
2383 v
2384 }),
2385 RecordTransform::custom(|mut v| {
2387 if let Value::Object(ref mut m) = v
2388 && let Some(val) = m.remove("raw_value")
2389 {
2390 m.insert("value".to_string(), val);
2391 }
2392 v
2393 }),
2394 ]),
2395 );
2396 assert_eq!(result["id"], 1);
2397 assert_eq!(result["value"], 200);
2398 assert!(result.get("raw_value").is_none());
2399 }
2400
2401 #[cfg(feature = "transform-flatten")]
2404 #[test]
2405 fn flatten_key_collision_errors() {
2406 let record = json!({"a__b": 1, "a": {"b": 2}});
2408 let err = super::apply_all(
2409 record,
2410 &compiled(&[RecordTransform::Flatten {
2411 separator: "__".into(),
2412 }]),
2413 )
2414 .expect_err("colliding flattened keys must error, not drop a value");
2415 assert!(matches!(err, FaucetError::Transform(_)));
2416 assert!(format!("{err}").contains("a__b"), "{err}");
2417 }
2418
2419 #[cfg(feature = "transform-select")]
2422 #[test]
2423 fn select_keeps_only_listed_fields() {
2424 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
2425 let result = apply_all(
2426 record,
2427 &compiled(&[RecordTransform::Select {
2428 fields: vec!["id".into(), "name".into()],
2429 }]),
2430 );
2431 assert_eq!(result["id"], 1);
2432 assert_eq!(result["name"], "Alice");
2433 assert!(result.get("secret").is_none());
2434 }
2435
2436 #[cfg(feature = "transform-select")]
2437 #[test]
2438 fn select_missing_field_is_no_op() {
2439 let record = json!({"id": 1});
2441 let result = apply_all(
2442 record,
2443 &compiled(&[RecordTransform::Select {
2444 fields: vec!["id".into(), "missing".into()],
2445 }]),
2446 );
2447 assert_eq!(result["id"], 1);
2448 assert!(result.get("missing").is_none());
2449 }
2450
2451 #[cfg(feature = "transform-select")]
2452 #[test]
2453 fn select_passes_through_non_object() {
2454 let record = json!([1, 2, 3]);
2455 let result = apply_all(
2456 record.clone(),
2457 &compiled(&[RecordTransform::Select {
2458 fields: vec!["id".into()],
2459 }]),
2460 );
2461 assert_eq!(result, record);
2462 }
2463
2464 #[cfg(feature = "transform-drop")]
2467 #[test]
2468 fn drop_removes_listed_fields() {
2469 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
2470 let result = apply_all(
2471 record,
2472 &compiled(&[RecordTransform::Drop {
2473 fields: vec!["ssn".into()],
2474 }]),
2475 );
2476 assert_eq!(result["id"], 1);
2477 assert_eq!(result["name"], "Alice");
2478 assert!(result.get("ssn").is_none());
2479 }
2480
2481 #[cfg(feature = "transform-drop")]
2482 #[test]
2483 fn drop_missing_field_is_no_op() {
2484 let record = json!({"id": 1});
2485 let result = apply_all(
2486 record,
2487 &compiled(&[RecordTransform::Drop {
2488 fields: vec!["missing".into()],
2489 }]),
2490 );
2491 assert_eq!(result["id"], 1);
2492 }
2493
2494 #[cfg(feature = "transform-set")]
2497 #[test]
2498 fn set_inserts_new_fields() {
2499 let record = json!({"id": 1});
2500 let mut values = Map::new();
2501 values.insert("_source".into(), json!("api"));
2502 values.insert("ingested_at".into(), json!("2026-01-01"));
2503 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2504 assert_eq!(result["id"], 1);
2505 assert_eq!(result["_source"], "api");
2506 assert_eq!(result["ingested_at"], "2026-01-01");
2507 }
2508
2509 #[cfg(feature = "transform-set")]
2510 #[test]
2511 fn set_overwrites_existing_field() {
2512 let record = json!({"_source": "old", "id": 1});
2513 let mut values = Map::new();
2514 values.insert("_source".into(), json!("new"));
2515 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2516 assert_eq!(result["_source"], "new");
2517 assert_eq!(result["id"], 1);
2518 }
2519
2520 #[cfg(feature = "transform-set")]
2521 #[test]
2522 fn set_supports_any_json_value() {
2523 let record = json!({});
2524 let mut values = Map::new();
2525 values.insert("n".into(), json!(42));
2526 values.insert("b".into(), json!(true));
2527 values.insert("arr".into(), json!([1, 2]));
2528 values.insert("obj".into(), json!({"k": "v"}));
2529 values.insert("null".into(), Value::Null);
2530 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
2531 assert_eq!(result["n"], 42);
2532 assert_eq!(result["b"], true);
2533 assert_eq!(result["arr"], json!([1, 2]));
2534 assert_eq!(result["obj"]["k"], "v");
2535 assert_eq!(result["null"], Value::Null);
2536 }
2537
2538 #[cfg(feature = "transform-rename-field")]
2541 #[test]
2542 fn rename_field_renames_exact_key() {
2543 let record = json!({"old_name": 1, "keep": 2});
2544 let mut fields = HashMap::new();
2545 fields.insert("old_name".to_owned(), "new_name".to_owned());
2546 let result = apply_all(
2547 record,
2548 &compiled(&[RecordTransform::RenameField { fields }]),
2549 );
2550 assert_eq!(result["new_name"], 1);
2551 assert_eq!(result["keep"], 2);
2552 assert!(result.get("old_name").is_none());
2553 }
2554
2555 #[cfg(feature = "transform-rename-field")]
2556 #[test]
2557 fn rename_field_missing_source_is_no_op() {
2558 let record = json!({"id": 1});
2559 let mut fields = HashMap::new();
2560 fields.insert("missing".to_owned(), "renamed".to_owned());
2561 let result = apply_all(
2562 record,
2563 &compiled(&[RecordTransform::RenameField { fields }]),
2564 );
2565 assert_eq!(result["id"], 1);
2566 assert!(result.get("renamed").is_none());
2567 }
2568
2569 #[cfg(feature = "transform-rename-field")]
2570 #[test]
2571 fn rename_field_target_collision_errors() {
2572 let record = json!({"a": 1, "b": 2});
2573 let mut fields = HashMap::new();
2574 fields.insert("a".to_owned(), "b".to_owned());
2575 let err = super::apply_all(
2576 record,
2577 &compiled(&[RecordTransform::RenameField { fields }]),
2578 )
2579 .expect_err("collision must error, not overwrite");
2580 assert!(matches!(err, FaucetError::Transform(_)));
2581 assert!(format!("{err}").contains("'b'"), "{err}");
2582 }
2583
2584 #[cfg(feature = "transform-rename-field")]
2585 #[test]
2586 fn rename_field_swap_is_deterministic() {
2587 for _ in 0..50 {
2590 let record = json!({"a": 1, "b": 2, "keep": 3});
2591 let mut fields = HashMap::new();
2592 fields.insert("a".to_owned(), "b".to_owned());
2593 fields.insert("b".to_owned(), "a".to_owned());
2594 let result = apply_all(
2595 record,
2596 &compiled(&[RecordTransform::RenameField { fields }]),
2597 );
2598 assert_eq!(result["a"], 2, "{result}");
2599 assert_eq!(result["b"], 1, "{result}");
2600 assert_eq!(result["keep"], 3);
2601 }
2602 }
2603
2604 #[cfg(feature = "transform-rename-field")]
2605 #[test]
2606 fn rename_field_chain_applies_against_original_snapshot() {
2607 for _ in 0..50 {
2610 let record = json!({"a": 1, "b": 2});
2611 let mut fields = HashMap::new();
2612 fields.insert("a".to_owned(), "b".to_owned());
2613 fields.insert("b".to_owned(), "c".to_owned());
2614 let result = apply_all(
2615 record,
2616 &compiled(&[RecordTransform::RenameField { fields }]),
2617 );
2618 assert_eq!(result["b"], 1, "{result}");
2619 assert_eq!(result["c"], 2, "{result}");
2620 assert!(result.get("a").is_none(), "{result}");
2621 }
2622 }
2623
2624 #[cfg(feature = "transform-rename-field")]
2625 #[test]
2626 fn rename_field_two_sources_one_target_errors() {
2627 let record = json!({"a": 1, "b": 2});
2628 let mut fields = HashMap::new();
2629 fields.insert("a".to_owned(), "c".to_owned());
2630 fields.insert("b".to_owned(), "c".to_owned());
2631 let err = super::apply_all(
2632 record,
2633 &compiled(&[RecordTransform::RenameField { fields }]),
2634 )
2635 .expect_err("two renames to the same target must error");
2636 assert!(format!("{err}").contains("same target"), "{err}");
2637 }
2638
2639 #[cfg(feature = "transform-cast")]
2642 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
2643 let mut fields = HashMap::new();
2644 fields.insert(field.to_owned(), ty);
2645 vec![RecordTransform::Cast { fields, on_error }]
2646 }
2647
2648 #[cfg(feature = "transform-cast")]
2649 #[test]
2650 fn cast_string_to_int() {
2651 let record = json!({"age": "42"});
2652 let result = apply_all(
2653 record,
2654 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
2655 );
2656 assert_eq!(result["age"], 42);
2657 }
2658
2659 #[cfg(feature = "transform-cast")]
2660 #[test]
2661 fn cast_whole_number_float_to_int_succeeds() {
2662 let record = json!({"n": 5.0});
2664 let result = apply_all(
2665 record,
2666 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2667 );
2668 assert_eq!(result["n"], 5);
2669 }
2670
2671 #[cfg(feature = "transform-cast")]
2672 #[test]
2673 fn cast_fractional_float_to_int_errors_under_on_error_error() {
2674 let record = json!({"n": 3.9});
2676 let err = super::apply_all(
2677 record,
2678 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2679 )
2680 .expect_err("a fractional float must not silently truncate to int");
2681 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
2682 }
2683
2684 #[cfg(feature = "transform-cast")]
2685 #[test]
2686 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
2687 let record = json!({"n": 1e30});
2689 let err = super::apply_all(
2690 record,
2691 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2692 )
2693 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
2694 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
2695 }
2696
2697 #[cfg(feature = "transform-cast")]
2698 #[test]
2699 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
2700 let record = json!({"n": 3.9});
2701 let result = apply_all(
2702 record,
2703 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
2704 );
2705 assert_eq!(result["n"], Value::Null);
2706 }
2707
2708 #[cfg(feature = "transform-cast")]
2709 #[test]
2710 fn cast_string_to_float() {
2711 let record = json!({"price": "9.99"});
2712 let result = apply_all(
2713 record,
2714 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
2715 );
2716 assert_eq!(result["price"], 9.99);
2717 }
2718
2719 #[cfg(feature = "transform-cast")]
2720 #[test]
2721 fn cast_string_to_bool() {
2722 for input in ["true", "TRUE", "1", "yes"] {
2723 let record = json!({"flag": input});
2724 let result = apply_all(
2725 record,
2726 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2727 );
2728 assert_eq!(result["flag"], true, "input was {input:?}");
2729 }
2730 for input in ["false", "0", "no"] {
2731 let record = json!({"flag": input});
2732 let result = apply_all(
2733 record,
2734 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2735 );
2736 assert_eq!(result["flag"], false, "input was {input:?}");
2737 }
2738 }
2739
2740 #[cfg(feature = "transform-cast")]
2741 #[test]
2742 fn cast_number_to_string() {
2743 let record = json!({"id": 42});
2744 let result = apply_all(
2745 record,
2746 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
2747 );
2748 assert_eq!(result["id"], "42");
2749 }
2750
2751 #[cfg(feature = "transform-cast")]
2752 #[test]
2753 fn cast_string_to_timestamp_normalises() {
2754 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
2755 let result = apply_all(
2756 record,
2757 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
2758 );
2759 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
2761 }
2762
2763 #[cfg(feature = "transform-cast")]
2764 #[test]
2765 fn cast_on_error_error_propagates() {
2766 let record = json!({"age": "not a number"});
2767 let err = super::apply_all(
2768 record,
2769 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
2770 )
2771 .expect_err("uncastable value must error under on_error=error");
2772 assert!(matches!(err, FaucetError::Transform(_)));
2773 assert!(format!("{err}").contains("'age'"), "{err}");
2774 }
2775
2776 #[cfg(feature = "transform-cast")]
2777 #[test]
2778 fn cast_on_error_null_replaces() {
2779 let record = json!({"age": "not a number"});
2780 let result = apply_all(
2781 record,
2782 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
2783 );
2784 assert_eq!(result["age"], Value::Null);
2785 }
2786
2787 #[cfg(feature = "transform-cast")]
2788 #[test]
2789 fn cast_on_error_skip_leaves_value() {
2790 let record = json!({"age": "not a number"});
2791 let result = apply_all(
2792 record,
2793 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
2794 );
2795 assert_eq!(result["age"], "not a number");
2796 }
2797
2798 #[cfg(feature = "transform-cast")]
2799 #[test]
2800 fn cast_missing_field_is_no_op() {
2801 let record = json!({"id": 1});
2802 let result = apply_all(
2803 record,
2804 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
2805 );
2806 assert_eq!(result["id"], 1);
2807 assert!(result.get("missing").is_none());
2808 }
2809
2810 #[cfg(feature = "transform-redact")]
2813 #[test]
2814 fn redact_replaces_value_with_mask() {
2815 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
2816 let result = apply_all(
2817 record,
2818 &compiled(&[RecordTransform::Redact {
2819 fields: vec!["ssn".into(), "email".into()],
2820 mask: json!("***"),
2821 }]),
2822 );
2823 assert_eq!(result["id"], 1);
2824 assert_eq!(result["ssn"], "***");
2825 assert_eq!(result["email"], "***");
2826 }
2827
2828 #[cfg(feature = "transform-redact")]
2829 #[test]
2830 fn redact_missing_field_does_not_insert_mask() {
2831 let record = json!({"id": 1});
2832 let result = apply_all(
2833 record,
2834 &compiled(&[RecordTransform::Redact {
2835 fields: vec!["ssn".into()],
2836 mask: json!("***"),
2837 }]),
2838 );
2839 assert_eq!(result["id"], 1);
2840 assert!(result.get("ssn").is_none());
2841 }
2842
2843 #[cfg(feature = "transform-value-case")]
2846 #[test]
2847 fn value_case_lower() {
2848 let record = json!({"email": "User@Example.COM", "id": 1});
2849 let result = apply_all(
2850 record,
2851 &compiled(&[RecordTransform::ValueCase {
2852 fields: vec!["email".into()],
2853 mode: ValueCaseMode::Lower,
2854 }]),
2855 );
2856 assert_eq!(result["email"], "user@example.com");
2857 assert_eq!(result["id"], 1);
2858 }
2859
2860 #[cfg(feature = "transform-value-case")]
2861 #[test]
2862 fn value_case_upper() {
2863 let record = json!({"code": "abc"});
2864 let result = apply_all(
2865 record,
2866 &compiled(&[RecordTransform::ValueCase {
2867 fields: vec!["code".into()],
2868 mode: ValueCaseMode::Upper,
2869 }]),
2870 );
2871 assert_eq!(result["code"], "ABC");
2872 }
2873
2874 #[cfg(feature = "transform-value-case")]
2875 #[test]
2876 fn value_case_trim() {
2877 let record = json!({"name": " Alice "});
2878 let result = apply_all(
2879 record,
2880 &compiled(&[RecordTransform::ValueCase {
2881 fields: vec!["name".into()],
2882 mode: ValueCaseMode::Trim,
2883 }]),
2884 );
2885 assert_eq!(result["name"], "Alice");
2886 }
2887
2888 #[cfg(feature = "transform-value-case")]
2889 #[test]
2890 fn value_case_passes_non_string_through() {
2891 let record = json!({"id": 42});
2892 let result = apply_all(
2893 record,
2894 &compiled(&[RecordTransform::ValueCase {
2895 fields: vec!["id".into()],
2896 mode: ValueCaseMode::Upper,
2897 }]),
2898 );
2899 assert_eq!(result["id"], 42);
2900 }
2901
2902 #[cfg(feature = "transform-spell-symbols")]
2905 fn spell_default() -> Vec<RecordTransform> {
2906 vec![RecordTransform::SpellSymbols {
2907 extra: HashMap::new(),
2908 separator: " ".into(),
2909 }]
2910 }
2911
2912 #[cfg(feature = "transform-spell-symbols")]
2913 #[test]
2914 fn spell_symbols_replaces_common_symbols() {
2915 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
2916 let result = apply_all(record, &compiled(&spell_default()));
2917 assert!(result.get(" percent sold").is_some());
2920 assert!(result.get("C number course").is_some());
2921 assert!(result.get(" dollar amount").is_some());
2922 }
2923
2924 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
2925 #[test]
2926 fn spell_symbols_then_keys_case_pipeline() {
2927 let record = json!({"% sold": 10, "C# courses": 20});
2928 let result = super::apply_all(
2929 record,
2930 &compiled(&[
2931 RecordTransform::SpellSymbols {
2932 extra: HashMap::new(),
2933 separator: " ".into(),
2934 },
2935 RecordTransform::KeysCase {
2936 mode: KeyCaseMode::Snake,
2937 },
2938 ]),
2939 )
2940 .expect("pipeline must succeed");
2941 assert_eq!(result["percent_sold"], 10);
2942 assert_eq!(result["c_number_courses"], 20);
2943 }
2944
2945 #[cfg(feature = "transform-spell-symbols")]
2946 #[test]
2947 fn spell_symbols_extra_overrides_defaults() {
2948 let mut extra = HashMap::new();
2949 extra.insert("#".to_owned(), "hash".to_owned());
2950 extra.insert("©".to_owned(), "copyright".to_owned());
2951 let record = json!({"#tag": 1, "©2026": 2});
2952 let result = apply_all(
2953 record,
2954 &compiled(&[RecordTransform::SpellSymbols {
2955 extra,
2956 separator: " ".into(),
2957 }]),
2958 );
2959 assert!(result.get(" hash tag").is_some());
2961 assert!(result.get(" copyright 2026").is_some());
2963 }
2964
2965 #[cfg(feature = "transform-spell-symbols")]
2966 #[test]
2967 fn spell_symbols_longest_match_wins() {
2968 let mut extra = HashMap::new();
2970 extra.insert("<=".to_owned(), "lte".to_owned());
2971 let record = json!({"a<=b": 1});
2972 let result = apply_all(
2973 record,
2974 &compiled(&[RecordTransform::SpellSymbols {
2975 extra,
2976 separator: " ".into(),
2977 }]),
2978 );
2979 assert!(result.get("a lte b").is_some());
2980 assert!(result.get("a lt = b").is_none());
2982 }
2983
2984 #[cfg(feature = "transform-spell-symbols")]
2985 #[test]
2986 fn spell_symbols_recursive_into_objects_and_arrays() {
2987 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
2988 let result = apply_all(record, &compiled(&spell_default()));
2989 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
2990 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
2991 let inner = &result[&outer_key];
2992 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
2993 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
2994 let deep = &inner[&inner_key][0];
2995 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
2996 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
2997 }
2998
2999 #[cfg(feature = "transform-spell-symbols")]
3000 #[test]
3001 fn spell_symbols_key_collision_errors() {
3002 let record = json!({"%": 1, "percent": 2});
3004 let err = super::apply_all(
3005 record,
3006 &compiled(&[RecordTransform::SpellSymbols {
3007 extra: HashMap::new(),
3008 separator: "".into(),
3009 }]),
3010 )
3011 .expect_err("colliding spelled keys must error, not drop a value");
3012 assert!(matches!(err, FaucetError::Transform(_)));
3013 assert!(format!("{err}").contains("percent"), "{err}");
3014 }
3015
3016 #[cfg(feature = "transform-keys-case")]
3019 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
3020 vec![RecordTransform::KeysCase { mode }]
3021 }
3022
3023 #[cfg(feature = "transform-keys-case")]
3024 #[test]
3025 fn keys_case_snake() {
3026 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
3027 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3028 assert_eq!(result["first_name"], 1);
3029 assert_eq!(result["last_name"], 2);
3030 assert_eq!(result["id"], 3);
3031 }
3032
3033 #[cfg(feature = "transform-keys-case")]
3034 #[test]
3035 fn keys_case_camel_from_various_inputs() {
3036 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
3038 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
3039 assert_eq!(result["firstName"], 1);
3040 assert_eq!(result["userId"], 2);
3041 assert_eq!(result["kebabCase"], 3);
3042 assert_eq!(result["pascalCase"], 4);
3043 }
3044
3045 #[cfg(feature = "transform-keys-case")]
3046 #[test]
3047 fn keys_case_pascal() {
3048 let record = json!({"first_name": 1, "second name": 2});
3049 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
3050 assert_eq!(result["FirstName"], 1);
3051 assert_eq!(result["SecondName"], 2);
3052 }
3053
3054 #[cfg(feature = "transform-keys-case")]
3055 #[test]
3056 fn keys_case_kebab() {
3057 let record = json!({"firstName": 1, "second_name": 2});
3058 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
3059 assert_eq!(result["first-name"], 1);
3060 assert_eq!(result["second-name"], 2);
3061 }
3062
3063 #[cfg(feature = "transform-keys-case")]
3064 #[test]
3065 fn keys_case_screaming_snake() {
3066 let record = json!({"firstName": 1, "second name": 2});
3067 let result = apply_all(
3068 record,
3069 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
3070 );
3071 assert_eq!(result["FIRST_NAME"], 1);
3072 assert_eq!(result["SECOND_NAME"], 2);
3073 }
3074
3075 #[cfg(feature = "transform-keys-case")]
3076 #[test]
3077 fn keys_case_recursive_into_nested() {
3078 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
3079 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3080 assert_eq!(result["user_info"]["first_name"], "Alice");
3081 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
3082 }
3083
3084 #[cfg(feature = "transform-keys-case")]
3085 #[test]
3086 fn keys_case_collision_errors() {
3087 let record = json!({"firstName": 1, "first_name": 2});
3089 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
3090 .expect_err("colliding re-cased keys must error, not drop a value");
3091 assert!(matches!(err, FaucetError::Transform(_)));
3092 assert!(format!("{err}").contains("first_name"), "{err}");
3093 }
3094
3095 #[cfg(feature = "transform-keys-case")]
3096 #[test]
3097 fn keys_case_all_symbol_key_kept_as_is() {
3098 let record = json!({"!@#": 1, "id": 2});
3101 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3102 assert_eq!(result["!@#"], 1);
3103 assert_eq!(result["id"], 2);
3104 }
3105
3106 #[cfg(feature = "transform-keys-case")]
3107 #[test]
3108 fn keys_case_idempotent_in_target_mode() {
3109 let record = json!({"first_name": 1});
3112 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
3113 let twice = apply_all(
3114 once.clone(),
3115 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
3116 );
3117 assert_eq!(once, twice);
3118 }
3119
3120 #[cfg(feature = "transform-spell-symbols")]
3121 #[test]
3122 fn spell_symbols_handles_unicode_keys() {
3123 let record = json!({"café%": 1});
3125 let result = apply_all(record, &compiled(&spell_default()));
3126 let key = result.as_object().unwrap().keys().next().unwrap().clone();
3127 assert!(key.contains("café"), "key was {key:?}");
3128 assert!(key.contains("percent"), "key was {key:?}");
3129 }
3130
3131 #[test]
3134 fn debug_record_transform_all_variants() {
3135 let dbg = format!("{:?}", RecordTransform::custom(|v| v));
3137 assert_eq!(dbg, "Custom(<fn>)");
3138
3139 #[cfg(feature = "transform-flatten")]
3140 {
3141 let dbg = format!(
3142 "{:?}",
3143 RecordTransform::Flatten {
3144 separator: "__".into()
3145 }
3146 );
3147 assert!(dbg.starts_with("Flatten"), "{dbg}");
3148 assert!(dbg.contains("separator"), "{dbg}");
3149 assert!(dbg.contains("__"), "{dbg}");
3150 }
3151 #[cfg(feature = "transform-rename-keys")]
3152 {
3153 let dbg = format!(
3154 "{:?}",
3155 RecordTransform::RenameKeys {
3156 pattern: "p".into(),
3157 replacement: "r".into(),
3158 }
3159 );
3160 assert!(dbg.starts_with("RenameKeys"), "{dbg}");
3161 assert!(dbg.contains("pattern"), "{dbg}");
3162 assert!(dbg.contains("replacement"), "{dbg}");
3163 }
3164 #[cfg(feature = "transform-keys-case")]
3165 {
3166 let dbg = format!(
3167 "{:?}",
3168 RecordTransform::KeysCase {
3169 mode: KeyCaseMode::Snake
3170 }
3171 );
3172 assert!(dbg.starts_with("KeysCase"), "{dbg}");
3173 assert!(dbg.contains("Snake"), "{dbg}");
3174 }
3175 #[cfg(feature = "transform-select")]
3176 {
3177 let dbg = format!(
3178 "{:?}",
3179 RecordTransform::Select {
3180 fields: vec!["a".into()]
3181 }
3182 );
3183 assert!(dbg.starts_with("Select"), "{dbg}");
3184 assert!(dbg.contains("fields"), "{dbg}");
3185 }
3186 #[cfg(feature = "transform-drop")]
3187 {
3188 let dbg = format!(
3189 "{:?}",
3190 RecordTransform::Drop {
3191 fields: vec!["a".into()]
3192 }
3193 );
3194 assert!(dbg.starts_with("Drop"), "{dbg}");
3195 }
3196 #[cfg(feature = "transform-set")]
3197 {
3198 let mut values = Map::new();
3199 values.insert("k".into(), json!("v"));
3200 let dbg = format!("{:?}", RecordTransform::Set { values });
3201 assert!(dbg.starts_with("Set"), "{dbg}");
3202 assert!(dbg.contains("values"), "{dbg}");
3203 }
3204 #[cfg(feature = "transform-rename-field")]
3205 {
3206 let mut fields = HashMap::new();
3207 fields.insert("a".to_owned(), "b".to_owned());
3208 let dbg = format!("{:?}", RecordTransform::RenameField { fields });
3209 assert!(dbg.starts_with("RenameField"), "{dbg}");
3210 }
3211 #[cfg(feature = "transform-cast")]
3212 {
3213 let mut fields = HashMap::new();
3214 fields.insert("a".to_owned(), CastType::Int);
3215 let dbg = format!(
3216 "{:?}",
3217 RecordTransform::Cast {
3218 fields,
3219 on_error: CastOnError::Error,
3220 }
3221 );
3222 assert!(dbg.starts_with("Cast"), "{dbg}");
3223 assert!(dbg.contains("on_error"), "{dbg}");
3224 }
3225 #[cfg(feature = "transform-redact")]
3226 {
3227 let dbg = format!(
3228 "{:?}",
3229 RecordTransform::Redact {
3230 fields: vec!["a".into()],
3231 mask: json!("***"),
3232 }
3233 );
3234 assert!(dbg.starts_with("Redact"), "{dbg}");
3235 assert!(dbg.contains("mask"), "{dbg}");
3236 }
3237 #[cfg(feature = "transform-value-case")]
3238 {
3239 let dbg = format!(
3240 "{:?}",
3241 RecordTransform::ValueCase {
3242 fields: vec!["a".into()],
3243 mode: ValueCaseMode::Lower,
3244 }
3245 );
3246 assert!(dbg.starts_with("ValueCase"), "{dbg}");
3247 assert!(dbg.contains("mode"), "{dbg}");
3248 }
3249 #[cfg(feature = "transform-spell-symbols")]
3250 {
3251 let dbg = format!(
3252 "{:?}",
3253 RecordTransform::SpellSymbols {
3254 extra: HashMap::new(),
3255 separator: " ".into(),
3256 }
3257 );
3258 assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
3259 assert!(dbg.contains("separator"), "{dbg}");
3260 }
3261 #[cfg(feature = "transform-hash")]
3262 {
3263 let dbg = format!(
3264 "{:?}",
3265 RecordTransform::Hash {
3266 fields: vec!["a".into()],
3267 algorithm: HashAlgorithm::Blake3,
3268 encoding: HashEncoding::Base64,
3269 salt: Some("s".into()),
3270 into: Some("a_hash".into()),
3271 }
3272 );
3273 assert!(dbg.starts_with("Hash"), "{dbg}");
3274 assert!(dbg.contains("algorithm"), "{dbg}");
3275 assert!(dbg.contains("encoding"), "{dbg}");
3276 }
3277 #[cfg(feature = "transform-json-parse")]
3278 {
3279 let dbg = format!(
3280 "{:?}",
3281 RecordTransform::JsonParse {
3282 fields: vec!["a".into()],
3283 on_error: JsonParseOnError::Null,
3284 into: None,
3285 }
3286 );
3287 assert!(dbg.starts_with("JsonParse"), "{dbg}");
3288 assert!(dbg.contains("on_error"), "{dbg}");
3289 }
3290 #[cfg(feature = "transform-coalesce")]
3291 {
3292 let dbg = format!(
3293 "{:?}",
3294 RecordTransform::Coalesce {
3295 field: "a".into(),
3296 default: Some(json!("x")),
3297 from: vec![],
3298 treat_empty_string_as_null: true,
3299 }
3300 );
3301 assert!(dbg.starts_with("Coalesce"), "{dbg}");
3302 assert!(dbg.contains("treat_empty_string_as_null"), "{dbg}");
3303 }
3304 #[cfg(feature = "transform-split-join")]
3305 {
3306 let dbg = format!(
3307 "{:?}",
3308 RecordTransform::Split {
3309 field: "a".into(),
3310 delimiter: ",".into(),
3311 trim: true,
3312 into: None,
3313 }
3314 );
3315 assert!(dbg.starts_with("Split"), "{dbg}");
3316 assert!(dbg.contains("delimiter"), "{dbg}");
3317 let dbg = format!(
3318 "{:?}",
3319 RecordTransform::Join {
3320 field: "a".into(),
3321 delimiter: ",".into(),
3322 into: None,
3323 }
3324 );
3325 assert!(dbg.starts_with("Join"), "{dbg}");
3326 }
3327 }
3328
3329 #[test]
3332 fn clone_record_transform_custom_preserves_behaviour() {
3333 let original = RecordTransform::custom(|mut v| {
3334 if let Value::Object(ref mut m) = v {
3335 m.insert("cloned".into(), json!(true));
3336 }
3337 v
3338 });
3339 let cloned = original.clone();
3340 assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
3341 let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
3342 assert_eq!(out["cloned"], true);
3343 assert_eq!(out["id"], 1);
3344 }
3345
3346 #[test]
3347 #[allow(clippy::vec_init_then_push)]
3350 fn clone_record_transform_all_builtin_variants() {
3351 let mut variants: Vec<RecordTransform> = Vec::new();
3352 #[cfg(feature = "transform-flatten")]
3353 variants.push(RecordTransform::Flatten {
3354 separator: "__".into(),
3355 });
3356 #[cfg(feature = "transform-rename-keys")]
3357 variants.push(RecordTransform::RenameKeys {
3358 pattern: "p".into(),
3359 replacement: "r".into(),
3360 });
3361 #[cfg(feature = "transform-keys-case")]
3362 variants.push(RecordTransform::KeysCase {
3363 mode: KeyCaseMode::Snake,
3364 });
3365 #[cfg(feature = "transform-select")]
3366 variants.push(RecordTransform::Select {
3367 fields: vec!["a".into()],
3368 });
3369 #[cfg(feature = "transform-drop")]
3370 variants.push(RecordTransform::Drop {
3371 fields: vec!["a".into()],
3372 });
3373 #[cfg(feature = "transform-set")]
3374 {
3375 let mut values = Map::new();
3376 values.insert("k".into(), json!("v"));
3377 variants.push(RecordTransform::Set { values });
3378 }
3379 #[cfg(feature = "transform-rename-field")]
3380 {
3381 let mut fields = HashMap::new();
3382 fields.insert("a".to_owned(), "b".to_owned());
3383 variants.push(RecordTransform::RenameField { fields });
3384 }
3385 #[cfg(feature = "transform-cast")]
3386 {
3387 let mut fields = HashMap::new();
3388 fields.insert("a".to_owned(), CastType::Int);
3389 variants.push(RecordTransform::Cast {
3390 fields,
3391 on_error: CastOnError::Error,
3392 });
3393 }
3394 #[cfg(feature = "transform-redact")]
3395 variants.push(RecordTransform::Redact {
3396 fields: vec!["a".into()],
3397 mask: json!("***"),
3398 });
3399 #[cfg(feature = "transform-value-case")]
3400 variants.push(RecordTransform::ValueCase {
3401 fields: vec!["a".into()],
3402 mode: ValueCaseMode::Lower,
3403 });
3404 #[cfg(feature = "transform-spell-symbols")]
3405 variants.push(RecordTransform::SpellSymbols {
3406 extra: HashMap::new(),
3407 separator: " ".into(),
3408 });
3409 #[cfg(feature = "transform-hash")]
3410 variants.push(RecordTransform::Hash {
3411 fields: vec!["a".into()],
3412 algorithm: HashAlgorithm::Sha256,
3413 encoding: HashEncoding::Hex,
3414 salt: Some("s".into()),
3415 into: None,
3416 });
3417 #[cfg(feature = "transform-json-parse")]
3418 variants.push(RecordTransform::JsonParse {
3419 fields: vec!["a".into()],
3420 on_error: JsonParseOnError::Keep,
3421 into: Some("b".into()),
3422 });
3423 #[cfg(feature = "transform-coalesce")]
3424 variants.push(RecordTransform::Coalesce {
3425 field: "a".into(),
3426 default: None,
3427 from: vec!["b".into()],
3428 treat_empty_string_as_null: false,
3429 });
3430 #[cfg(feature = "transform-split-join")]
3431 {
3432 variants.push(RecordTransform::Split {
3433 field: "a".into(),
3434 delimiter: ",".into(),
3435 trim: true,
3436 into: None,
3437 });
3438 variants.push(RecordTransform::Join {
3439 field: "a".into(),
3440 delimiter: ",".into(),
3441 into: Some("b".into()),
3442 });
3443 }
3444
3445 for v in &variants {
3447 let cloned = v.clone();
3448 assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
3449 }
3450 }
3451
3452 #[test]
3453 fn clone_compiled_transform_all_variants() {
3454 let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
3455 #[cfg(feature = "transform-flatten")]
3456 specs.push(RecordTransform::Flatten {
3457 separator: "__".into(),
3458 });
3459 #[cfg(feature = "transform-rename-keys")]
3460 specs.push(RecordTransform::RenameKeys {
3461 pattern: "p".into(),
3462 replacement: "r".into(),
3463 });
3464 #[cfg(feature = "transform-keys-case")]
3465 specs.push(RecordTransform::KeysCase {
3466 mode: KeyCaseMode::Camel,
3467 });
3468 #[cfg(feature = "transform-select")]
3469 specs.push(RecordTransform::Select {
3470 fields: vec!["a".into()],
3471 });
3472 #[cfg(feature = "transform-drop")]
3473 specs.push(RecordTransform::Drop {
3474 fields: vec!["a".into()],
3475 });
3476 #[cfg(feature = "transform-set")]
3477 {
3478 let mut values = Map::new();
3479 values.insert("k".into(), json!("v"));
3480 specs.push(RecordTransform::Set { values });
3481 }
3482 #[cfg(feature = "transform-rename-field")]
3483 {
3484 let mut fields = HashMap::new();
3485 fields.insert("a".to_owned(), "b".to_owned());
3486 specs.push(RecordTransform::RenameField { fields });
3487 }
3488 #[cfg(feature = "transform-cast")]
3489 {
3490 let mut fields = HashMap::new();
3491 fields.insert("a".to_owned(), CastType::Int);
3492 specs.push(RecordTransform::Cast {
3493 fields,
3494 on_error: CastOnError::Null,
3495 });
3496 }
3497 #[cfg(feature = "transform-redact")]
3498 specs.push(RecordTransform::Redact {
3499 fields: vec!["a".into()],
3500 mask: json!("***"),
3501 });
3502 #[cfg(feature = "transform-value-case")]
3503 specs.push(RecordTransform::ValueCase {
3504 fields: vec!["a".into()],
3505 mode: ValueCaseMode::Upper,
3506 });
3507 #[cfg(feature = "transform-spell-symbols")]
3508 specs.push(RecordTransform::SpellSymbols {
3509 extra: HashMap::new(),
3510 separator: " ".into(),
3511 });
3512 #[cfg(feature = "transform-hash")]
3513 specs.push(RecordTransform::Hash {
3514 fields: vec!["a".into()],
3515 algorithm: HashAlgorithm::Blake3,
3516 encoding: HashEncoding::Base64,
3517 salt: None,
3518 into: None,
3519 });
3520 #[cfg(feature = "transform-json-parse")]
3521 specs.push(RecordTransform::JsonParse {
3522 fields: vec!["a".into()],
3523 on_error: JsonParseOnError::Error,
3524 into: None,
3525 });
3526 #[cfg(feature = "transform-coalesce")]
3527 specs.push(RecordTransform::Coalesce {
3528 field: "a".into(),
3529 default: Some(json!("x")),
3530 from: vec![],
3531 treat_empty_string_as_null: true,
3532 });
3533 #[cfg(feature = "transform-split-join")]
3534 {
3535 specs.push(RecordTransform::Split {
3536 field: "a".into(),
3537 delimiter: ",".into(),
3538 trim: false,
3539 into: None,
3540 });
3541 specs.push(RecordTransform::Join {
3542 field: "a".into(),
3543 delimiter: ",".into(),
3544 into: None,
3545 });
3546 }
3547
3548 let original = compiled(&specs);
3551 let cloned: Vec<CompiledTransform> = original.to_vec();
3552 assert_eq!(original.len(), cloned.len());
3553 let record = json!({"a": "1", "k": "x"});
3554 let out_orig = super::apply_all(record.clone(), &original);
3555 let out_clone = super::apply_all(record, &cloned);
3556 assert_eq!(
3557 out_orig.is_ok(),
3558 out_clone.is_ok(),
3559 "clone must transform identically"
3560 );
3561 if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
3562 assert_eq!(a, b);
3563 }
3564 }
3565
3566 #[cfg(feature = "transform-flatten")]
3569 #[test]
3570 fn flatten_passes_through_non_object() {
3571 let record = json!([1, 2, 3]);
3572 let result = apply_all(
3573 record.clone(),
3574 &compiled(&[RecordTransform::Flatten {
3575 separator: "__".into(),
3576 }]),
3577 );
3578 assert_eq!(result, record);
3579 let scalar = json!(42);
3581 let result = apply_all(
3582 scalar.clone(),
3583 &compiled(&[RecordTransform::Flatten {
3584 separator: "__".into(),
3585 }]),
3586 );
3587 assert_eq!(result, scalar);
3588 }
3589
3590 #[cfg(feature = "transform-drop")]
3591 #[test]
3592 fn drop_passes_through_non_object() {
3593 let record = json!([1, 2]);
3594 let result = apply_all(
3595 record.clone(),
3596 &compiled(&[RecordTransform::Drop {
3597 fields: vec!["a".into()],
3598 }]),
3599 );
3600 assert_eq!(result, record);
3601 }
3602
3603 #[cfg(feature = "transform-set")]
3604 #[test]
3605 fn set_passes_through_non_object() {
3606 let mut values = Map::new();
3607 values.insert("k".into(), json!("v"));
3608 let record = json!("scalar");
3609 let result = apply_all(
3610 record.clone(),
3611 &compiled(&[RecordTransform::Set { values }]),
3612 );
3613 assert_eq!(result, record);
3614 }
3615
3616 #[cfg(feature = "transform-rename-field")]
3617 #[test]
3618 fn rename_field_passes_through_non_object() {
3619 let mut fields = HashMap::new();
3620 fields.insert("a".to_owned(), "b".to_owned());
3621 let record = json!([1, 2]);
3622 let result = apply_all(
3623 record.clone(),
3624 &compiled(&[RecordTransform::RenameField { fields }]),
3625 );
3626 assert_eq!(result, record);
3627 }
3628
3629 #[cfg(feature = "transform-rename-field")]
3630 #[test]
3631 fn rename_field_same_name_is_skipped() {
3632 let mut fields = HashMap::new();
3634 fields.insert("a".to_owned(), "a".to_owned());
3635 let record = json!({"a": 1});
3636 let result = apply_all(
3637 record,
3638 &compiled(&[RecordTransform::RenameField { fields }]),
3639 );
3640 assert_eq!(result["a"], 1);
3641 }
3642
3643 #[cfg(feature = "transform-cast")]
3644 #[test]
3645 fn cast_passes_through_non_object() {
3646 let record = json!([1, 2]);
3647 let result = apply_all(
3648 record.clone(),
3649 &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
3650 );
3651 assert_eq!(result, record);
3652 }
3653
3654 #[cfg(feature = "transform-redact")]
3655 #[test]
3656 fn redact_passes_through_non_object() {
3657 let record = json!("scalar");
3658 let result = apply_all(
3659 record.clone(),
3660 &compiled(&[RecordTransform::Redact {
3661 fields: vec!["a".into()],
3662 mask: json!("***"),
3663 }]),
3664 );
3665 assert_eq!(result, record);
3666 }
3667
3668 #[cfg(feature = "transform-value-case")]
3669 #[test]
3670 fn value_case_passes_through_non_object() {
3671 let record = json!([1, 2]);
3672 let result = apply_all(
3673 record.clone(),
3674 &compiled(&[RecordTransform::ValueCase {
3675 fields: vec!["a".into()],
3676 mode: ValueCaseMode::Lower,
3677 }]),
3678 );
3679 assert_eq!(result, record);
3680 }
3681
3682 #[cfg(feature = "transform-cast")]
3685 #[test]
3686 fn cast_integer_number_to_int_is_identity() {
3687 let record = json!({"n": 7});
3689 let result = apply_all(
3690 record,
3691 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3692 );
3693 assert_eq!(result["n"], 7);
3694 }
3695
3696 #[cfg(feature = "transform-cast")]
3697 #[test]
3698 fn cast_bool_to_int() {
3699 let record = json!({"t": true, "f": false});
3700 let mut fields = HashMap::new();
3701 fields.insert("t".to_owned(), CastType::Int);
3702 fields.insert("f".to_owned(), CastType::Int);
3703 let result = apply_all(
3704 record,
3705 &compiled(&[RecordTransform::Cast {
3706 fields,
3707 on_error: CastOnError::Error,
3708 }]),
3709 );
3710 assert_eq!(result["t"], 1);
3711 assert_eq!(result["f"], 0);
3712 }
3713
3714 #[cfg(feature = "transform-cast")]
3715 #[test]
3716 fn cast_null_to_int_errors() {
3717 let record = json!({"n": null});
3718 let err = super::apply_all(
3719 record,
3720 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3721 )
3722 .expect_err("null cannot become int");
3723 assert!(
3724 format!("{err}").contains("null cannot be cast to int"),
3725 "{err}"
3726 );
3727 }
3728
3729 #[cfg(feature = "transform-cast")]
3730 #[test]
3731 fn cast_composite_to_int_errors() {
3732 let record = json!({"n": [1, 2]});
3733 let err = super::apply_all(
3734 record,
3735 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
3736 )
3737 .expect_err("array cannot become int");
3738 assert!(format!("{err}").contains("composite"), "{err}");
3739 }
3740
3741 #[cfg(feature = "transform-cast")]
3742 #[test]
3743 fn cast_number_to_float() {
3744 let record = json!({"n": 5});
3745 let result = apply_all(
3746 record,
3747 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3748 );
3749 assert_eq!(result["n"], 5.0);
3750 }
3751
3752 #[cfg(feature = "transform-cast")]
3753 #[test]
3754 fn cast_bool_to_float() {
3755 let record = json!({"t": true, "f": false});
3756 let mut fields = HashMap::new();
3757 fields.insert("t".to_owned(), CastType::Float);
3758 fields.insert("f".to_owned(), CastType::Float);
3759 let result = apply_all(
3760 record,
3761 &compiled(&[RecordTransform::Cast {
3762 fields,
3763 on_error: CastOnError::Error,
3764 }]),
3765 );
3766 assert_eq!(result["t"], 1.0);
3767 assert_eq!(result["f"], 0.0);
3768 }
3769
3770 #[cfg(feature = "transform-cast")]
3771 #[test]
3772 fn cast_null_to_float_errors() {
3773 let record = json!({"n": null});
3774 let err = super::apply_all(
3775 record,
3776 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3777 )
3778 .expect_err("null cannot become float");
3779 assert!(
3780 format!("{err}").contains("null cannot be cast to float"),
3781 "{err}"
3782 );
3783 }
3784
3785 #[cfg(feature = "transform-cast")]
3786 #[test]
3787 fn cast_composite_to_float_errors() {
3788 let record = json!({"n": {"x": 1}});
3789 let err = super::apply_all(
3790 record,
3791 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3792 )
3793 .expect_err("object cannot become float");
3794 assert!(format!("{err}").contains("composite"), "{err}");
3795 }
3796
3797 #[cfg(feature = "transform-cast")]
3798 #[test]
3799 fn cast_string_to_float_invalid_errors() {
3800 let record = json!({"n": "not a float"});
3801 let err = super::apply_all(
3802 record,
3803 &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
3804 )
3805 .expect_err("non-numeric string cannot become float");
3806 assert!(format!("{err}").contains("is not a float"), "{err}");
3807 }
3808
3809 #[cfg(feature = "transform-cast")]
3810 #[test]
3811 fn cast_bool_to_bool_is_identity() {
3812 let record = json!({"b": true});
3813 let result = apply_all(
3814 record,
3815 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
3816 );
3817 assert_eq!(result["b"], true);
3818 }
3819
3820 #[cfg(feature = "transform-cast")]
3821 #[test]
3822 fn cast_number_to_bool() {
3823 let record = json!({"on": 1, "off": 0});
3824 let mut fields = HashMap::new();
3825 fields.insert("on".to_owned(), CastType::Bool);
3826 fields.insert("off".to_owned(), CastType::Bool);
3827 let result = apply_all(
3828 record,
3829 &compiled(&[RecordTransform::Cast {
3830 fields,
3831 on_error: CastOnError::Error,
3832 }]),
3833 );
3834 assert_eq!(result["on"], true);
3835 assert_eq!(result["off"], false);
3836 }
3837
3838 #[cfg(feature = "transform-cast")]
3839 #[test]
3840 fn cast_integer_other_than_zero_one_to_bool_errors() {
3841 let record = json!({"n": 7});
3842 let err = super::apply_all(
3843 record,
3844 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
3845 )
3846 .expect_err("only 0/1 convert to bool");
3847 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
3848 }
3849
3850 #[cfg(feature = "transform-cast")]
3851 #[test]
3852 fn cast_float_number_to_bool_errors() {
3853 let record = json!({"n": 1.5});
3855 let err = super::apply_all(
3856 record,
3857 &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
3858 )
3859 .expect_err("fractional number cannot become bool");
3860 assert!(format!("{err}").contains("not 0 or 1"), "{err}");
3861 }
3862
3863 #[cfg(feature = "transform-cast")]
3864 #[test]
3865 fn cast_unrecognised_string_to_bool_errors() {
3866 let record = json!({"flag": "maybe"});
3867 let err = super::apply_all(
3868 record,
3869 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
3870 )
3871 .expect_err("'maybe' is not a boolean");
3872 assert!(
3873 format!("{err}").contains("not a recognised boolean"),
3874 "{err}"
3875 );
3876 }
3877
3878 #[cfg(feature = "transform-cast")]
3879 #[test]
3880 fn cast_null_to_bool_errors() {
3881 let record = json!({"b": null});
3882 let err = super::apply_all(
3883 record,
3884 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
3885 )
3886 .expect_err("null cannot become bool");
3887 assert!(
3888 format!("{err}").contains("null cannot be cast to bool"),
3889 "{err}"
3890 );
3891 }
3892
3893 #[cfg(feature = "transform-cast")]
3894 #[test]
3895 fn cast_composite_to_bool_errors() {
3896 let record = json!({"b": [true]});
3897 let err = super::apply_all(
3898 record,
3899 &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
3900 )
3901 .expect_err("array cannot become bool");
3902 assert!(format!("{err}").contains("composite"), "{err}");
3903 }
3904
3905 #[cfg(feature = "transform-cast")]
3906 #[test]
3907 fn cast_string_to_string_is_identity() {
3908 let record = json!({"s": "hello"});
3909 let result = apply_all(
3910 record,
3911 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
3912 );
3913 assert_eq!(result["s"], "hello");
3914 }
3915
3916 #[cfg(feature = "transform-cast")]
3917 #[test]
3918 fn cast_bool_to_string() {
3919 let record = json!({"b": true});
3920 let result = apply_all(
3921 record,
3922 &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
3923 );
3924 assert_eq!(result["b"], "true");
3925 }
3926
3927 #[cfg(feature = "transform-cast")]
3928 #[test]
3929 fn cast_null_to_string_errors() {
3930 let record = json!({"s": null});
3931 let err = super::apply_all(
3932 record,
3933 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
3934 )
3935 .expect_err("null cannot become string");
3936 assert!(
3937 format!("{err}").contains("null cannot be cast to string"),
3938 "{err}"
3939 );
3940 }
3941
3942 #[cfg(feature = "transform-cast")]
3943 #[test]
3944 fn cast_composite_to_string_errors() {
3945 let record = json!({"s": {"a": 1}});
3946 let err = super::apply_all(
3947 record,
3948 &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
3949 )
3950 .expect_err("object cannot become string");
3951 assert!(format!("{err}").contains("composite"), "{err}");
3952 }
3953
3954 #[cfg(feature = "transform-cast")]
3955 #[test]
3956 fn cast_invalid_timestamp_string_errors() {
3957 let record = json!({"ts": "not a date"});
3958 let err = super::apply_all(
3959 record,
3960 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3961 )
3962 .expect_err("invalid timestamp string");
3963 assert!(format!("{err}").contains("RFC 3339"), "{err}");
3964 }
3965
3966 #[cfg(feature = "transform-cast")]
3967 #[test]
3968 fn cast_non_string_to_timestamp_names_the_type() {
3969 for (val, ty_name) in [
3971 (json!(null), "null"),
3972 (json!(true), "bool"),
3973 (json!(42), "number"),
3974 (json!([1, 2]), "array"),
3975 (json!({"a": 1}), "object"),
3976 ] {
3977 let record = json!({ "ts": val });
3978 let err = super::apply_all(
3979 record,
3980 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3981 )
3982 .expect_err("non-string cannot become timestamp");
3983 let msg = format!("{err}");
3984 assert!(msg.contains("timestamp"), "{msg}");
3985 assert!(
3986 msg.contains(ty_name),
3987 "expected type name {ty_name:?} in: {msg}"
3988 );
3989 }
3990 }
3991
3992 #[cfg(feature = "transform-hash")]
3995 fn hash_spec(fields: &[&str], enc: HashEncoding, salt: Option<&str>) -> Vec<RecordTransform> {
3996 vec![RecordTransform::Hash {
3997 fields: fields.iter().map(|s| (*s).to_owned()).collect(),
3998 algorithm: HashAlgorithm::Sha256,
3999 encoding: enc,
4000 salt: salt.map(str::to_owned),
4001 into: None,
4002 }]
4003 }
4004
4005 #[cfg(feature = "transform-hash")]
4006 #[test]
4007 fn hash_replaces_in_place_and_is_stable() {
4008 let a = apply_all(
4009 json!({"email": "a@b.com", "id": 1}),
4010 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4011 );
4012 let b = apply_all(
4013 json!({"email": "a@b.com", "id": 1}),
4014 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4015 );
4016 assert_eq!(a["email"], b["email"]);
4018 assert_eq!(a["id"], 1);
4019 assert_eq!(a["email"].as_str().unwrap().len(), 64);
4021 assert_ne!(a["email"], json!("a@b.com"));
4022 }
4023
4024 #[cfg(feature = "transform-hash")]
4025 #[test]
4026 fn hash_salt_changes_output() {
4027 let unsalted = apply_all(
4028 json!({"email": "a@b.com"}),
4029 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4030 );
4031 let salted = apply_all(
4032 json!({"email": "a@b.com"}),
4033 &compiled(&hash_spec(&["email"], HashEncoding::Hex, Some("pepper"))),
4034 );
4035 assert_ne!(unsalted["email"], salted["email"]);
4036 }
4037
4038 #[cfg(feature = "transform-hash")]
4039 #[test]
4040 fn hash_hex_vs_base64_differ_and_both_decode() {
4041 let hex = apply_all(
4042 json!({"v": "x"}),
4043 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4044 );
4045 let b64 = apply_all(
4046 json!({"v": "x"}),
4047 &compiled(&hash_spec(&["v"], HashEncoding::Base64, None)),
4048 );
4049 assert_ne!(hex["v"], b64["v"]);
4050 assert_eq!(hex["v"].as_str().unwrap().len(), 64);
4052 assert_eq!(b64["v"].as_str().unwrap().len(), 44);
4053 }
4054
4055 #[cfg(feature = "transform-hash")]
4056 #[test]
4057 fn hash_into_preserves_source() {
4058 let out = apply_all(
4059 json!({"email": "a@b.com"}),
4060 &compiled(&[RecordTransform::Hash {
4061 fields: vec!["email".into()],
4062 algorithm: HashAlgorithm::Sha256,
4063 encoding: HashEncoding::Hex,
4064 salt: None,
4065 into: Some("email_hash".into()),
4066 }]),
4067 );
4068 assert_eq!(out["email"], "a@b.com");
4069 assert_eq!(out["email_hash"].as_str().unwrap().len(), 64);
4070 }
4071
4072 #[cfg(feature = "transform-hash")]
4073 #[test]
4074 fn hash_missing_field_is_no_op() {
4075 let out = apply_all(
4076 json!({"id": 1}),
4077 &compiled(&hash_spec(&["email"], HashEncoding::Hex, None)),
4078 );
4079 assert_eq!(out, json!({"id": 1}));
4080 }
4081
4082 #[cfg(feature = "transform-hash")]
4083 #[test]
4084 fn hash_non_string_hashes_canonical_json() {
4085 let out = apply_all(
4087 json!({"n": 42}),
4088 &compiled(&hash_spec(&["n"], HashEncoding::Hex, None)),
4089 );
4090 let expected = hash_string("42", HashAlgorithm::Sha256, HashEncoding::Hex, None);
4091 assert_eq!(out["n"], Value::String(expected));
4092 }
4093
4094 #[cfg(feature = "transform-hash")]
4095 #[test]
4096 fn hash_blake3_differs_from_sha256() {
4097 let sha = apply_all(
4098 json!({"v": "x"}),
4099 &compiled(&[RecordTransform::Hash {
4100 fields: vec!["v".into()],
4101 algorithm: HashAlgorithm::Sha256,
4102 encoding: HashEncoding::Hex,
4103 salt: None,
4104 into: None,
4105 }]),
4106 );
4107 let b3 = apply_all(
4108 json!({"v": "x"}),
4109 &compiled(&[RecordTransform::Hash {
4110 fields: vec!["v".into()],
4111 algorithm: HashAlgorithm::Blake3,
4112 encoding: HashEncoding::Hex,
4113 salt: None,
4114 into: None,
4115 }]),
4116 );
4117 assert_ne!(sha["v"], b3["v"]);
4118 assert_eq!(b3["v"].as_str().unwrap().len(), 64);
4119 }
4120
4121 #[cfg(feature = "transform-hash")]
4122 #[test]
4123 fn hash_empty_fields_is_config_error() {
4124 let res = compile(&RecordTransform::Hash {
4125 fields: vec![],
4126 algorithm: HashAlgorithm::Sha256,
4127 encoding: HashEncoding::Hex,
4128 salt: None,
4129 into: None,
4130 });
4131 assert!(matches!(res, Err(FaucetError::Config(_))));
4132 }
4133
4134 #[cfg(feature = "transform-hash")]
4135 #[test]
4136 fn hash_into_with_multiple_fields_is_config_error() {
4137 let res = compile(&RecordTransform::Hash {
4138 fields: vec!["a".into(), "b".into()],
4139 algorithm: HashAlgorithm::Sha256,
4140 encoding: HashEncoding::Hex,
4141 salt: None,
4142 into: Some("x".into()),
4143 });
4144 assert!(matches!(res, Err(FaucetError::Config(_))));
4145 }
4146
4147 #[cfg(feature = "transform-hash")]
4148 #[test]
4149 fn hash_debug_redacts_salt() {
4150 let dbg = format!(
4151 "{:?}",
4152 RecordTransform::Hash {
4153 fields: vec!["a".into()],
4154 algorithm: HashAlgorithm::Sha256,
4155 encoding: HashEncoding::Hex,
4156 salt: Some("supersecret".into()),
4157 into: None,
4158 }
4159 );
4160 assert!(!dbg.contains("supersecret"), "{dbg}");
4161 assert!(dbg.contains("redacted"), "{dbg}");
4162 }
4163
4164 #[cfg(feature = "transform-json-parse")]
4167 fn json_parse_spec(field: &str, on_error: JsonParseOnError) -> Vec<RecordTransform> {
4168 vec![RecordTransform::JsonParse {
4169 fields: vec![field.to_owned()],
4170 on_error,
4171 into: None,
4172 }]
4173 }
4174
4175 #[cfg(feature = "transform-json-parse")]
4176 #[test]
4177 fn json_parse_object_string_becomes_object() {
4178 let out = apply_all(
4179 json!({"payload": "{\"a\":1,\"b\":[2,3]}"}),
4180 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4181 );
4182 assert_eq!(out["payload"], json!({"a": 1, "b": [2, 3]}));
4183 }
4184
4185 #[cfg(feature = "transform-json-parse")]
4186 #[test]
4187 fn json_parse_already_parsed_is_no_op() {
4188 let record = json!({"payload": {"a": 1}});
4189 let out = apply_all(
4190 record.clone(),
4191 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4192 );
4193 assert_eq!(out, record);
4194 }
4195
4196 #[cfg(feature = "transform-json-parse")]
4197 #[test]
4198 fn json_parse_missing_field_is_no_op() {
4199 let out = apply_all(
4200 json!({"id": 1}),
4201 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4202 );
4203 assert_eq!(out, json!({"id": 1}));
4204 }
4205
4206 #[cfg(feature = "transform-json-parse")]
4207 #[test]
4208 fn json_parse_invalid_keep_leaves_string() {
4209 let out = apply_all(
4210 json!({"payload": "not json"}),
4211 &compiled(&json_parse_spec("payload", JsonParseOnError::Keep)),
4212 );
4213 assert_eq!(out["payload"], "not json");
4214 }
4215
4216 #[cfg(feature = "transform-json-parse")]
4217 #[test]
4218 fn json_parse_invalid_null_replaces() {
4219 let out = apply_all(
4220 json!({"payload": "not json"}),
4221 &compiled(&json_parse_spec("payload", JsonParseOnError::Null)),
4222 );
4223 assert_eq!(out["payload"], Value::Null);
4224 }
4225
4226 #[cfg(feature = "transform-json-parse")]
4227 #[test]
4228 fn json_parse_invalid_error_propagates() {
4229 let err = super::apply_all(
4230 json!({"payload": "not json"}),
4231 &compiled(&json_parse_spec("payload", JsonParseOnError::Error)),
4232 )
4233 .expect_err("invalid JSON under on_error=error must fail");
4234 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
4235 }
4236
4237 #[cfg(feature = "transform-json-parse")]
4238 #[test]
4239 fn json_parse_into_writes_target() {
4240 let out = apply_all(
4241 json!({"payload": "{\"a\":1}"}),
4242 &compiled(&[RecordTransform::JsonParse {
4243 fields: vec!["payload".into()],
4244 on_error: JsonParseOnError::Error,
4245 into: Some("parsed".into()),
4246 }]),
4247 );
4248 assert_eq!(out["payload"], "{\"a\":1}");
4249 assert_eq!(out["parsed"], json!({"a": 1}));
4250 }
4251
4252 #[cfg(feature = "transform-coalesce")]
4255 #[test]
4256 fn coalesce_default_fills_null_and_absent() {
4257 let spec = |field: &str| {
4258 vec![RecordTransform::Coalesce {
4259 field: field.to_owned(),
4260 default: Some(json!("unknown")),
4261 from: vec![],
4262 treat_empty_string_as_null: false,
4263 }]
4264 };
4265 let a = apply_all(json!({"status": null}), &compiled(&spec("status")));
4267 assert_eq!(a["status"], "unknown");
4268 let b = apply_all(json!({"id": 1}), &compiled(&spec("status")));
4270 assert_eq!(b["status"], "unknown");
4271 }
4272
4273 #[cfg(feature = "transform-coalesce")]
4274 #[test]
4275 fn coalesce_non_null_target_untouched() {
4276 let out = apply_all(
4277 json!({"status": "active"}),
4278 &compiled(&[RecordTransform::Coalesce {
4279 field: "status".into(),
4280 default: Some(json!("unknown")),
4281 from: vec![],
4282 treat_empty_string_as_null: false,
4283 }]),
4284 );
4285 assert_eq!(out["status"], "active");
4286 }
4287
4288 #[cfg(feature = "transform-coalesce")]
4289 #[test]
4290 fn coalesce_from_picks_first_non_null() {
4291 let out = apply_all(
4292 json!({"status": null, "state": null, "phase": "running"}),
4293 &compiled(&[RecordTransform::Coalesce {
4294 field: "status".into(),
4295 default: None,
4296 from: vec!["status".into(), "state".into(), "phase".into()],
4297 treat_empty_string_as_null: false,
4298 }]),
4299 );
4300 assert_eq!(out["status"], "running");
4301 }
4302
4303 #[cfg(feature = "transform-coalesce")]
4304 #[test]
4305 fn coalesce_empty_string_toggle() {
4306 let spec = |treat: bool| {
4307 vec![RecordTransform::Coalesce {
4308 field: "status".into(),
4309 default: Some(json!("unknown")),
4310 from: vec![],
4311 treat_empty_string_as_null: treat,
4312 }]
4313 };
4314 let off = apply_all(json!({"status": ""}), &compiled(&spec(false)));
4316 assert_eq!(off["status"], "");
4317 let on = apply_all(json!({"status": ""}), &compiled(&spec(true)));
4319 assert_eq!(on["status"], "unknown");
4320 }
4321
4322 #[cfg(feature = "transform-coalesce")]
4323 #[test]
4324 fn coalesce_from_all_null_leaves_target() {
4325 let out = apply_all(
4326 json!({"status": null, "state": null}),
4327 &compiled(&[RecordTransform::Coalesce {
4328 field: "status".into(),
4329 default: None,
4330 from: vec!["status".into(), "state".into()],
4331 treat_empty_string_as_null: false,
4332 }]),
4333 );
4334 assert_eq!(out["status"], Value::Null);
4335 }
4336
4337 #[cfg(feature = "transform-coalesce")]
4338 #[test]
4339 fn coalesce_both_default_and_from_is_config_error() {
4340 let res = compile(&RecordTransform::Coalesce {
4341 field: "status".into(),
4342 default: Some(json!("x")),
4343 from: vec!["state".into()],
4344 treat_empty_string_as_null: false,
4345 });
4346 assert!(matches!(res, Err(FaucetError::Config(_))));
4347 }
4348
4349 #[cfg(feature = "transform-coalesce")]
4350 #[test]
4351 fn coalesce_neither_default_nor_from_is_config_error() {
4352 let res = compile(&RecordTransform::Coalesce {
4353 field: "status".into(),
4354 default: None,
4355 from: vec![],
4356 treat_empty_string_as_null: false,
4357 });
4358 assert!(matches!(res, Err(FaucetError::Config(_))));
4359 }
4360
4361 #[cfg(feature = "transform-split-join")]
4364 #[test]
4365 fn split_basic_no_trim() {
4366 let out = apply_all(
4367 json!({"tags": "a, b ,c"}),
4368 &compiled(&[RecordTransform::Split {
4369 field: "tags".into(),
4370 delimiter: ",".into(),
4371 trim: false,
4372 into: None,
4373 }]),
4374 );
4375 assert_eq!(out["tags"], json!(["a", " b ", "c"]));
4376 }
4377
4378 #[cfg(feature = "transform-split-join")]
4379 #[test]
4380 fn split_with_trim_keeps_empty_segments() {
4381 let out = apply_all(
4382 json!({"tags": "a, ,c,"}),
4383 &compiled(&[RecordTransform::Split {
4384 field: "tags".into(),
4385 delimiter: ",".into(),
4386 trim: true,
4387 into: None,
4388 }]),
4389 );
4390 assert_eq!(out["tags"], json!(["a", "", "c", ""]));
4392 }
4393
4394 #[cfg(feature = "transform-split-join")]
4395 #[test]
4396 fn split_empty_input_yields_single_empty() {
4397 let out = apply_all(
4398 json!({"tags": ""}),
4399 &compiled(&[RecordTransform::Split {
4400 field: "tags".into(),
4401 delimiter: ",".into(),
4402 trim: false,
4403 into: None,
4404 }]),
4405 );
4406 assert_eq!(out["tags"], json!([""]));
4407 }
4408
4409 #[cfg(feature = "transform-split-join")]
4410 #[test]
4411 fn split_non_string_is_no_op() {
4412 let record = json!({"tags": [1, 2]});
4413 let out = apply_all(
4414 record.clone(),
4415 &compiled(&[RecordTransform::Split {
4416 field: "tags".into(),
4417 delimiter: ",".into(),
4418 trim: false,
4419 into: None,
4420 }]),
4421 );
4422 assert_eq!(out, record);
4423 }
4424
4425 #[cfg(feature = "transform-split-join")]
4426 #[test]
4427 fn split_into_writes_target() {
4428 let out = apply_all(
4429 json!({"csv": "a,b"}),
4430 &compiled(&[RecordTransform::Split {
4431 field: "csv".into(),
4432 delimiter: ",".into(),
4433 trim: false,
4434 into: Some("arr".into()),
4435 }]),
4436 );
4437 assert_eq!(out["csv"], "a,b");
4438 assert_eq!(out["arr"], json!(["a", "b"]));
4439 }
4440
4441 #[cfg(feature = "transform-split-join")]
4442 #[test]
4443 fn join_basic_and_non_string_elements() {
4444 let out = apply_all(
4445 json!({"parts": ["a", 2, true, null]}),
4446 &compiled(&[RecordTransform::Join {
4447 field: "parts".into(),
4448 delimiter: ",".into(),
4449 into: None,
4450 }]),
4451 );
4452 assert_eq!(out["parts"], "a,2,true,");
4454 }
4455
4456 #[cfg(feature = "transform-split-join")]
4457 #[test]
4458 fn join_non_array_is_no_op() {
4459 let record = json!({"parts": "already a string"});
4460 let out = apply_all(
4461 record.clone(),
4462 &compiled(&[RecordTransform::Join {
4463 field: "parts".into(),
4464 delimiter: ",".into(),
4465 into: None,
4466 }]),
4467 );
4468 assert_eq!(out, record);
4469 }
4470
4471 #[cfg(feature = "transform-split-join")]
4472 #[test]
4473 fn split_then_join_round_trips() {
4474 let out = apply_all(
4475 json!({"tags": "a,b,c"}),
4476 &compiled(&[
4477 RecordTransform::Split {
4478 field: "tags".into(),
4479 delimiter: ",".into(),
4480 trim: false,
4481 into: None,
4482 },
4483 RecordTransform::Join {
4484 field: "tags".into(),
4485 delimiter: ",".into(),
4486 into: None,
4487 },
4488 ]),
4489 );
4490 assert_eq!(out["tags"], "a,b,c");
4491 }
4492
4493 #[cfg(feature = "transform-value-case")]
4496 #[test]
4497 fn value_case_title() {
4498 let out = apply_all(
4499 json!({"city": "new york", "id": 1}),
4500 &compiled(&[RecordTransform::ValueCase {
4501 fields: vec!["city".into()],
4502 mode: ValueCaseMode::Title,
4503 }]),
4504 );
4505 assert_eq!(out["city"], "New York");
4506 assert_eq!(out["id"], 1);
4507 }
4508
4509 #[cfg(feature = "transform-value-case")]
4510 #[test]
4511 fn value_case_title_lowercases_rest_of_word() {
4512 let out = apply_all(
4513 json!({"s": "hELLO WORLD"}),
4514 &compiled(&[RecordTransform::ValueCase {
4515 fields: vec!["s".into()],
4516 mode: ValueCaseMode::Title,
4517 }]),
4518 );
4519 assert_eq!(out["s"], "Hello World");
4520 }
4521
4522 #[cfg(feature = "transform-value-case")]
4523 #[test]
4524 fn value_case_capitalize() {
4525 let out = apply_all(
4526 json!({"s": "hELLO wORLD"}),
4527 &compiled(&[RecordTransform::ValueCase {
4528 fields: vec!["s".into()],
4529 mode: ValueCaseMode::Capitalize,
4530 }]),
4531 );
4532 assert_eq!(out["s"], "Hello world");
4533 }
4534
4535 #[cfg(feature = "transform-value-case")]
4536 #[test]
4537 fn value_case_title_is_idempotent() {
4538 let once = apply_all(
4539 json!({"s": "new york"}),
4540 &compiled(&[RecordTransform::ValueCase {
4541 fields: vec!["s".into()],
4542 mode: ValueCaseMode::Title,
4543 }]),
4544 );
4545 let twice = apply_all(
4546 once.clone(),
4547 &compiled(&[RecordTransform::ValueCase {
4548 fields: vec!["s".into()],
4549 mode: ValueCaseMode::Title,
4550 }]),
4551 );
4552 assert_eq!(once, twice);
4553 }
4554
4555 #[cfg(feature = "transform-value-case")]
4556 #[test]
4557 fn value_case_title_non_string_no_op() {
4558 let out = apply_all(
4559 json!({"n": 42}),
4560 &compiled(&[RecordTransform::ValueCase {
4561 fields: vec!["n".into()],
4562 mode: ValueCaseMode::Title,
4563 }]),
4564 );
4565 assert_eq!(out["n"], 42);
4566 }
4567
4568 #[cfg(feature = "transform-keys-case")]
4571 #[test]
4572 fn keys_case_dot() {
4573 let out = apply_all(
4574 json!({"userId": 1, "First Name": 2, "kebab-case": 3}),
4575 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4576 );
4577 assert_eq!(out["user.id"], 1);
4578 assert_eq!(out["first.name"], 2);
4579 assert_eq!(out["kebab.case"], 3);
4580 }
4581
4582 #[cfg(feature = "transform-keys-case")]
4583 #[test]
4584 fn keys_case_dot_is_idempotent() {
4585 let once = apply_all(
4586 json!({"userId": 1}),
4587 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4588 );
4589 let twice = apply_all(once.clone(), &compiled(&keys_case_specs(KeyCaseMode::Dot)));
4590 assert_eq!(once, twice);
4591 assert_eq!(twice["user.id"], 1);
4592 }
4593
4594 #[cfg(feature = "transform-keys-case")]
4595 #[test]
4596 fn keys_case_dot_matches_snake_tokenization() {
4597 let record = json!({"XMLHttpRequest": 1, "second name": 2});
4599 let dot = apply_all(
4600 record.clone(),
4601 &compiled(&keys_case_specs(KeyCaseMode::Dot)),
4602 );
4603 let snake = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
4604 let dot_keys: Vec<String> = dot.as_object().unwrap().keys().cloned().collect();
4606 let snake_keys: Vec<String> = snake.as_object().unwrap().keys().cloned().collect();
4607 let converted: Vec<String> = snake_keys.iter().map(|k| k.replace('_', ".")).collect();
4608 assert_eq!(dot_keys, converted);
4609 }
4610
4611 #[cfg(feature = "transform-json-parse")]
4614 #[test]
4615 fn json_parse_empty_fields_is_config_error() {
4616 let res = compile(&RecordTransform::JsonParse {
4617 fields: vec![],
4618 on_error: JsonParseOnError::Keep,
4619 into: None,
4620 });
4621 assert!(matches!(res, Err(FaucetError::Config(_))));
4622 }
4623
4624 #[cfg(feature = "transform-json-parse")]
4625 #[test]
4626 fn json_parse_into_with_multiple_fields_is_config_error() {
4627 let res = compile(&RecordTransform::JsonParse {
4628 fields: vec!["a".into(), "b".into()],
4629 on_error: JsonParseOnError::Keep,
4630 into: Some("x".into()),
4631 });
4632 assert!(matches!(res, Err(FaucetError::Config(_))));
4633 }
4634
4635 #[cfg(feature = "transform-hash")]
4636 #[test]
4637 fn hash_passes_through_non_object() {
4638 let record = json!([1, 2, 3]);
4639 let result = apply_all(
4640 record.clone(),
4641 &compiled(&hash_spec(&["v"], HashEncoding::Hex, None)),
4642 );
4643 assert_eq!(result, record);
4644 }
4645
4646 #[cfg(feature = "transform-json-parse")]
4647 #[test]
4648 fn json_parse_passes_through_non_object() {
4649 let record = json!("scalar");
4650 let result = apply_all(
4651 record.clone(),
4652 &compiled(&json_parse_spec("v", JsonParseOnError::Error)),
4653 );
4654 assert_eq!(result, record);
4655 }
4656
4657 #[cfg(feature = "transform-coalesce")]
4658 #[test]
4659 fn coalesce_non_null_non_string_target_untouched() {
4660 let out = apply_all(
4663 json!({"n": 0}),
4664 &compiled(&[RecordTransform::Coalesce {
4665 field: "n".into(),
4666 default: Some(json!(99)),
4667 from: vec![],
4668 treat_empty_string_as_null: true,
4669 }]),
4670 );
4671 assert_eq!(out["n"], 0);
4672 }
4673
4674 #[cfg(feature = "transform-coalesce")]
4675 #[test]
4676 fn coalesce_passes_through_non_object() {
4677 let record = json!([1, 2]);
4678 let result = apply_all(
4679 record.clone(),
4680 &compiled(&[RecordTransform::Coalesce {
4681 field: "a".into(),
4682 default: Some(json!("x")),
4683 from: vec![],
4684 treat_empty_string_as_null: false,
4685 }]),
4686 );
4687 assert_eq!(result, record);
4688 }
4689
4690 #[cfg(feature = "transform-split-join")]
4691 #[test]
4692 fn split_and_join_pass_through_non_object() {
4693 let record = json!(42);
4694 let split = apply_all(
4695 record.clone(),
4696 &compiled(&[RecordTransform::Split {
4697 field: "a".into(),
4698 delimiter: ",".into(),
4699 trim: false,
4700 into: None,
4701 }]),
4702 );
4703 assert_eq!(split, record);
4704 let join = apply_all(
4705 record.clone(),
4706 &compiled(&[RecordTransform::Join {
4707 field: "a".into(),
4708 delimiter: ",".into(),
4709 into: None,
4710 }]),
4711 );
4712 assert_eq!(join, record);
4713 }
4714
4715 #[cfg(feature = "transform-split-join")]
4716 #[test]
4717 fn split_empty_delimiter_yields_single_element() {
4718 let out = apply_all(
4719 json!({"s": " hi "}),
4720 &compiled(&[RecordTransform::Split {
4721 field: "s".into(),
4722 delimiter: String::new(),
4723 trim: true,
4724 into: None,
4725 }]),
4726 );
4727 assert_eq!(out["s"], json!(["hi"]));
4729 }
4730
4731 #[cfg(feature = "transform-value-case")]
4732 #[test]
4733 fn value_case_title_and_capitalize_handle_empty_string() {
4734 for mode in [ValueCaseMode::Title, ValueCaseMode::Capitalize] {
4735 let out = apply_all(
4736 json!({"s": ""}),
4737 &compiled(&[RecordTransform::ValueCase {
4738 fields: vec!["s".into()],
4739 mode,
4740 }]),
4741 );
4742 assert_eq!(out["s"], "");
4743 }
4744 }
4745}