1use crate::error::FaucetError;
43#[cfg(any(
44 feature = "transform-flatten",
45 feature = "transform-rename-keys",
46 feature = "transform-keys-case",
47 feature = "transform-set",
48))]
49use serde_json::Map;
50use serde_json::Value;
51use std::fmt;
52use std::sync::Arc;
53
54#[cfg(any(
55 feature = "transform-cast",
56 feature = "transform-rename-field",
57 feature = "transform-value-case",
58 feature = "transform-spell-symbols",
59))]
60use std::collections::HashMap;
61
62#[cfg(feature = "transform-rename-keys")]
63use regex::Regex;
64
65#[cfg(feature = "transform-cast")]
74#[derive(
75 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
76)]
77#[serde(rename_all = "lowercase")]
78pub enum CastType {
79 Int,
81 Float,
83 Bool,
86 String,
88 Timestamp,
90}
91
92#[cfg(feature = "transform-cast")]
94#[derive(
95 Debug,
96 Clone,
97 Copy,
98 PartialEq,
99 Eq,
100 serde::Deserialize,
101 serde::Serialize,
102 schemars::JsonSchema,
103 Default,
104)]
105#[serde(rename_all = "lowercase")]
106pub enum CastOnError {
107 #[default]
109 Error,
110 Null,
112 Skip,
114}
115
116#[cfg(feature = "transform-keys-case")]
122#[derive(
123 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
124)]
125#[serde(rename_all = "snake_case")]
126pub enum KeyCaseMode {
127 Snake,
129 Camel,
132 Pascal,
134 Kebab,
136 ScreamingSnake,
138}
139
140#[cfg(feature = "transform-value-case")]
142#[derive(
143 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
144)]
145#[serde(rename_all = "lowercase")]
146pub enum ValueCaseMode {
147 Lower,
149 Upper,
151 Trim,
153}
154
155pub enum RecordTransform {
167 #[cfg(feature = "transform-flatten")]
180 Flatten { separator: String },
181
182 #[cfg(feature = "transform-rename-keys")]
197 RenameKeys {
198 pattern: String,
199 replacement: String,
200 },
201
202 #[cfg(feature = "transform-keys-case")]
217 KeysCase { mode: KeyCaseMode },
218
219 #[cfg(feature = "transform-select")]
226 Select { fields: Vec<String> },
227
228 #[cfg(feature = "transform-drop")]
234 Drop { fields: Vec<String> },
235
236 #[cfg(feature = "transform-set")]
243 Set { values: Map<String, Value> },
244
245 #[cfg(feature = "transform-rename-field")]
254 RenameField {
255 fields: HashMap<String, String>,
257 },
258
259 #[cfg(feature = "transform-cast")]
267 Cast {
268 fields: HashMap<String, CastType>,
269 on_error: CastOnError,
270 },
271
272 #[cfg(feature = "transform-redact")]
279 Redact { fields: Vec<String>, mask: Value },
280
281 #[cfg(feature = "transform-value-case")]
288 ValueCase {
289 fields: Vec<String>,
290 mode: ValueCaseMode,
291 },
292
293 #[cfg(feature = "transform-spell-symbols")]
314 SpellSymbols {
315 extra: HashMap<String, String>,
318 separator: String,
321 },
322
323 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
330}
331
332impl fmt::Debug for RecordTransform {
333 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
334 match self {
335 #[cfg(feature = "transform-flatten")]
336 Self::Flatten { separator } => f
337 .debug_struct("Flatten")
338 .field("separator", separator)
339 .finish(),
340 #[cfg(feature = "transform-rename-keys")]
341 Self::RenameKeys {
342 pattern,
343 replacement,
344 } => f
345 .debug_struct("RenameKeys")
346 .field("pattern", pattern)
347 .field("replacement", replacement)
348 .finish(),
349 #[cfg(feature = "transform-keys-case")]
350 Self::KeysCase { mode } => f.debug_struct("KeysCase").field("mode", mode).finish(),
351 #[cfg(feature = "transform-select")]
352 Self::Select { fields } => f.debug_struct("Select").field("fields", fields).finish(),
353 #[cfg(feature = "transform-drop")]
354 Self::Drop { fields } => f.debug_struct("Drop").field("fields", fields).finish(),
355 #[cfg(feature = "transform-set")]
356 Self::Set { values } => f.debug_struct("Set").field("values", values).finish(),
357 #[cfg(feature = "transform-rename-field")]
358 Self::RenameField { fields } => f
359 .debug_struct("RenameField")
360 .field("fields", fields)
361 .finish(),
362 #[cfg(feature = "transform-cast")]
363 Self::Cast { fields, on_error } => f
364 .debug_struct("Cast")
365 .field("fields", fields)
366 .field("on_error", on_error)
367 .finish(),
368 #[cfg(feature = "transform-redact")]
369 Self::Redact { fields, mask } => f
370 .debug_struct("Redact")
371 .field("fields", fields)
372 .field("mask", mask)
373 .finish(),
374 #[cfg(feature = "transform-value-case")]
375 Self::ValueCase { fields, mode } => f
376 .debug_struct("ValueCase")
377 .field("fields", fields)
378 .field("mode", mode)
379 .finish(),
380 #[cfg(feature = "transform-spell-symbols")]
381 Self::SpellSymbols { extra, separator } => f
382 .debug_struct("SpellSymbols")
383 .field("extra", extra)
384 .field("separator", separator)
385 .finish(),
386 Self::Custom(_) => write!(f, "Custom(<fn>)"),
387 }
388 }
389}
390
391impl Clone for RecordTransform {
394 fn clone(&self) -> Self {
395 match self {
396 #[cfg(feature = "transform-flatten")]
397 Self::Flatten { separator } => Self::Flatten {
398 separator: separator.clone(),
399 },
400 #[cfg(feature = "transform-rename-keys")]
401 Self::RenameKeys {
402 pattern,
403 replacement,
404 } => Self::RenameKeys {
405 pattern: pattern.clone(),
406 replacement: replacement.clone(),
407 },
408 #[cfg(feature = "transform-keys-case")]
409 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
410 #[cfg(feature = "transform-select")]
411 Self::Select { fields } => Self::Select {
412 fields: fields.clone(),
413 },
414 #[cfg(feature = "transform-drop")]
415 Self::Drop { fields } => Self::Drop {
416 fields: fields.clone(),
417 },
418 #[cfg(feature = "transform-set")]
419 Self::Set { values } => Self::Set {
420 values: values.clone(),
421 },
422 #[cfg(feature = "transform-rename-field")]
423 Self::RenameField { fields } => Self::RenameField {
424 fields: fields.clone(),
425 },
426 #[cfg(feature = "transform-cast")]
427 Self::Cast { fields, on_error } => Self::Cast {
428 fields: fields.clone(),
429 on_error: *on_error,
430 },
431 #[cfg(feature = "transform-redact")]
432 Self::Redact { fields, mask } => Self::Redact {
433 fields: fields.clone(),
434 mask: mask.clone(),
435 },
436 #[cfg(feature = "transform-value-case")]
437 Self::ValueCase { fields, mode } => Self::ValueCase {
438 fields: fields.clone(),
439 mode: *mode,
440 },
441 #[cfg(feature = "transform-spell-symbols")]
442 Self::SpellSymbols { extra, separator } => Self::SpellSymbols {
443 extra: extra.clone(),
444 separator: separator.clone(),
445 },
446 Self::Custom(f) => Self::Custom(Arc::clone(f)),
447 }
448 }
449}
450
451impl Clone for CompiledTransform {
454 fn clone(&self) -> Self {
455 match self {
456 #[cfg(feature = "transform-flatten")]
457 Self::Flatten { separator } => Self::Flatten {
458 separator: separator.clone(),
459 },
460 #[cfg(feature = "transform-rename-keys")]
461 Self::RenameKeys { re, replacement } => Self::RenameKeys {
462 re: re.clone(),
463 replacement: replacement.clone(),
464 },
465 #[cfg(feature = "transform-keys-case")]
466 Self::KeysCase { mode } => Self::KeysCase { mode: *mode },
467 #[cfg(feature = "transform-select")]
468 Self::Select { fields } => Self::Select {
469 fields: fields.clone(),
470 },
471 #[cfg(feature = "transform-drop")]
472 Self::Drop { fields } => Self::Drop {
473 fields: fields.clone(),
474 },
475 #[cfg(feature = "transform-set")]
476 Self::Set { values } => Self::Set {
477 values: values.clone(),
478 },
479 #[cfg(feature = "transform-rename-field")]
480 Self::RenameField { fields } => Self::RenameField {
481 fields: fields.clone(),
482 },
483 #[cfg(feature = "transform-cast")]
484 Self::Cast { fields, on_error } => Self::Cast {
485 fields: fields.clone(),
486 on_error: *on_error,
487 },
488 #[cfg(feature = "transform-redact")]
489 Self::Redact { fields, mask } => Self::Redact {
490 fields: fields.clone(),
491 mask: mask.clone(),
492 },
493 #[cfg(feature = "transform-value-case")]
494 Self::ValueCase { fields, mode } => Self::ValueCase {
495 fields: fields.clone(),
496 mode: *mode,
497 },
498 #[cfg(feature = "transform-spell-symbols")]
499 Self::SpellSymbols {
500 replacements,
501 separator,
502 } => Self::SpellSymbols {
503 replacements: replacements.clone(),
504 separator: separator.clone(),
505 },
506 Self::Custom(f) => Self::Custom(Arc::clone(f)),
507 }
508 }
509}
510
511impl RecordTransform {
512 pub fn custom<F>(f: F) -> Self
536 where
537 F: Fn(Value) -> Value + Send + Sync + 'static,
538 {
539 Self::Custom(Arc::new(f))
540 }
541}
542
543pub enum CompiledTransform {
551 #[cfg(feature = "transform-flatten")]
552 Flatten {
553 separator: String,
554 },
555 #[cfg(feature = "transform-rename-keys")]
556 RenameKeys {
557 re: Regex,
558 replacement: String,
559 },
560 #[cfg(feature = "transform-keys-case")]
561 KeysCase {
562 mode: KeyCaseMode,
563 },
564 #[cfg(feature = "transform-select")]
565 Select {
566 fields: Vec<String>,
567 },
568 #[cfg(feature = "transform-drop")]
569 Drop {
570 fields: Vec<String>,
571 },
572 #[cfg(feature = "transform-set")]
573 Set {
574 values: Map<String, Value>,
575 },
576 #[cfg(feature = "transform-rename-field")]
577 RenameField {
578 fields: HashMap<String, String>,
579 },
580 #[cfg(feature = "transform-cast")]
581 Cast {
582 fields: HashMap<String, CastType>,
583 on_error: CastOnError,
584 },
585 #[cfg(feature = "transform-redact")]
586 Redact {
587 fields: Vec<String>,
588 mask: Value,
589 },
590 #[cfg(feature = "transform-value-case")]
591 ValueCase {
592 fields: Vec<String>,
593 mode: ValueCaseMode,
594 },
595 #[cfg(feature = "transform-spell-symbols")]
596 SpellSymbols {
597 replacements: Vec<(String, String)>,
600 separator: String,
601 },
602 Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
603}
604
605pub fn compile(t: &RecordTransform) -> Result<CompiledTransform, FaucetError> {
609 match t {
610 #[cfg(feature = "transform-flatten")]
611 RecordTransform::Flatten { separator } => Ok(CompiledTransform::Flatten {
612 separator: separator.clone(),
613 }),
614 #[cfg(feature = "transform-rename-keys")]
615 RecordTransform::RenameKeys {
616 pattern,
617 replacement,
618 } => {
619 let re = Regex::new(pattern)
620 .map_err(|e| FaucetError::Transform(format!("invalid regex '{pattern}': {e}")))?;
621 Ok(CompiledTransform::RenameKeys {
622 re,
623 replacement: replacement.clone(),
624 })
625 }
626 #[cfg(feature = "transform-keys-case")]
627 RecordTransform::KeysCase { mode } => Ok(CompiledTransform::KeysCase { mode: *mode }),
628 #[cfg(feature = "transform-select")]
629 RecordTransform::Select { fields } => Ok(CompiledTransform::Select {
630 fields: fields.clone(),
631 }),
632 #[cfg(feature = "transform-drop")]
633 RecordTransform::Drop { fields } => Ok(CompiledTransform::Drop {
634 fields: fields.clone(),
635 }),
636 #[cfg(feature = "transform-set")]
637 RecordTransform::Set { values } => Ok(CompiledTransform::Set {
638 values: values.clone(),
639 }),
640 #[cfg(feature = "transform-rename-field")]
641 RecordTransform::RenameField { fields } => Ok(CompiledTransform::RenameField {
642 fields: fields.clone(),
643 }),
644 #[cfg(feature = "transform-cast")]
645 RecordTransform::Cast { fields, on_error } => Ok(CompiledTransform::Cast {
646 fields: fields.clone(),
647 on_error: *on_error,
648 }),
649 #[cfg(feature = "transform-redact")]
650 RecordTransform::Redact { fields, mask } => Ok(CompiledTransform::Redact {
651 fields: fields.clone(),
652 mask: mask.clone(),
653 }),
654 #[cfg(feature = "transform-value-case")]
655 RecordTransform::ValueCase { fields, mode } => Ok(CompiledTransform::ValueCase {
656 fields: fields.clone(),
657 mode: *mode,
658 }),
659 #[cfg(feature = "transform-spell-symbols")]
660 RecordTransform::SpellSymbols { extra, separator } => {
661 let mut merged = default_symbol_map();
664 for (k, v) in extra {
665 merged.insert(k.clone(), v.clone());
666 }
667 let mut replacements: Vec<(String, String)> = merged.into_iter().collect();
668 replacements.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
669 Ok(CompiledTransform::SpellSymbols {
670 replacements,
671 separator: separator.clone(),
672 })
673 }
674 RecordTransform::Custom(f) => Ok(CompiledTransform::Custom(Arc::clone(f))),
675 }
676}
677
678pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
684 let mut acc = record;
685 for t in transforms {
686 acc = apply_one(acc, t)?;
687 }
688 Ok(acc)
689}
690
691fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
692 match t {
693 #[cfg(feature = "transform-flatten")]
694 CompiledTransform::Flatten { separator } => flatten(value, separator),
695 #[cfg(feature = "transform-rename-keys")]
696 CompiledTransform::RenameKeys { re, replacement } => {
697 Ok(rename_keys(value, re, replacement))
698 }
699 #[cfg(feature = "transform-keys-case")]
700 CompiledTransform::KeysCase { mode } => keys_case(value, *mode),
701 #[cfg(feature = "transform-select")]
702 CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
703 #[cfg(feature = "transform-drop")]
704 CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
705 #[cfg(feature = "transform-set")]
706 CompiledTransform::Set { values } => Ok(set_fields(value, values)),
707 #[cfg(feature = "transform-rename-field")]
708 CompiledTransform::RenameField { fields } => rename_field(value, fields),
709 #[cfg(feature = "transform-cast")]
710 CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
711 #[cfg(feature = "transform-redact")]
712 CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
713 #[cfg(feature = "transform-value-case")]
714 CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
715 #[cfg(feature = "transform-spell-symbols")]
716 CompiledTransform::SpellSymbols {
717 replacements,
718 separator,
719 } => spell_symbols(value, replacements, separator),
720 CompiledTransform::Custom(f) => Ok(f(value)),
721 }
722}
723
724#[cfg(feature = "transform-flatten")]
727fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
728 match value {
729 Value::Object(_) => {
730 let mut out = Map::new();
731 flatten_into(value, "", separator, &mut out)?;
732 Ok(Value::Object(out))
733 }
734 other => Ok(other),
735 }
736}
737
738#[cfg(feature = "transform-flatten")]
739fn flatten_into(
740 value: Value,
741 prefix: &str,
742 separator: &str,
743 out: &mut Map<String, Value>,
744) -> Result<(), FaucetError> {
745 match value {
746 Value::Object(map) => {
747 for (k, v) in map {
748 let key = if prefix.is_empty() {
749 k
750 } else {
751 format!("{prefix}{separator}{k}")
752 };
753 flatten_into(v, &key, separator, out)?;
754 }
755 }
756 other => {
757 if out.contains_key(prefix) {
761 return Err(FaucetError::Transform(format!(
762 "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
763 to the same flattened key (separator '{separator}')"
764 )));
765 }
766 out.insert(prefix.to_string(), other);
767 }
768 }
769 Ok(())
770}
771
772#[cfg(feature = "transform-rename-keys")]
775fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Value {
776 match value {
777 Value::Object(map) => {
778 let new_map: Map<String, Value> = map
779 .into_iter()
780 .map(|(k, v)| {
781 let new_k = re.replace_all(&k, replacement).into_owned();
782 (new_k, rename_keys(v, re, replacement))
783 })
784 .collect();
785 Value::Object(new_map)
786 }
787 Value::Array(arr) => Value::Array(
788 arr.into_iter()
789 .map(|v| rename_keys(v, re, replacement))
790 .collect(),
791 ),
792 other => other,
793 }
794}
795
796#[cfg(feature = "transform-keys-case")]
800fn keys_case(value: Value, mode: KeyCaseMode) -> Result<Value, FaucetError> {
801 match value {
802 Value::Object(map) => {
803 let mut new_map = Map::with_capacity(map.len());
804 for (k, v) in map {
805 let tokens = tokenize_key(&k);
806 let recased = if tokens.is_empty() {
807 k
810 } else {
811 apply_key_case(tokens, mode)
812 };
813 let new_v = keys_case(v, mode)?;
814 if new_map.contains_key(&recased) {
815 return Err(FaucetError::Transform(format!(
816 "keys_case produced a duplicate key '{recased}'; two distinct keys \
817 re-case to the same name under mode {mode:?}"
818 )));
819 }
820 new_map.insert(recased, new_v);
821 }
822 Ok(Value::Object(new_map))
823 }
824 Value::Array(arr) => {
825 let mut out = Vec::with_capacity(arr.len());
826 for v in arr {
827 out.push(keys_case(v, mode)?);
828 }
829 Ok(Value::Array(out))
830 }
831 other => Ok(other),
832 }
833}
834
835#[cfg(feature = "transform-keys-case")]
841fn tokenize_key(key: &str) -> Vec<String> {
842 let mut tokens: Vec<String> = Vec::new();
843 let mut current = String::new();
844 let mut prev_was_lower = false;
845 for ch in key.chars() {
846 if ch.is_alphanumeric() {
847 if prev_was_lower && ch.is_uppercase() && !current.is_empty() {
848 tokens.push(std::mem::take(&mut current));
849 }
850 current.push(ch);
851 prev_was_lower = ch.is_lowercase();
852 } else {
853 if !current.is_empty() {
854 tokens.push(std::mem::take(&mut current));
855 }
856 prev_was_lower = false;
857 }
858 }
859 if !current.is_empty() {
860 tokens.push(current);
861 }
862 tokens
863}
864
865#[cfg(feature = "transform-keys-case")]
866fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
867 match mode {
868 KeyCaseMode::Snake => tokens
869 .iter()
870 .map(|t| t.to_lowercase())
871 .collect::<Vec<_>>()
872 .join("_"),
873 KeyCaseMode::ScreamingSnake => tokens
874 .iter()
875 .map(|t| t.to_uppercase())
876 .collect::<Vec<_>>()
877 .join("_"),
878 KeyCaseMode::Kebab => tokens
879 .iter()
880 .map(|t| t.to_lowercase())
881 .collect::<Vec<_>>()
882 .join("-"),
883 KeyCaseMode::Camel => {
884 let mut iter = tokens.into_iter();
885 match iter.next() {
886 None => String::new(),
887 Some(first) => {
888 let mut out = first.to_lowercase();
889 for t in iter {
890 out.push_str(&capitalize_token(&t));
891 }
892 out
893 }
894 }
895 }
896 KeyCaseMode::Pascal => tokens
897 .into_iter()
898 .map(|t| capitalize_token(&t))
899 .collect::<String>(),
900 }
901}
902
903#[cfg(feature = "transform-keys-case")]
905fn capitalize_token(s: &str) -> String {
906 let lower = s.to_lowercase();
907 let mut chars = lower.chars();
908 match chars.next() {
909 None => String::new(),
910 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
911 }
912}
913
914#[cfg(feature = "transform-select")]
917fn select_fields(value: Value, fields: &[String]) -> Value {
918 match value {
919 Value::Object(map) => {
920 let mut out = Map::with_capacity(fields.len().min(map.len()));
921 for f in fields {
923 if let Some(v) = map.get(f) {
924 out.insert(f.clone(), v.clone());
925 }
926 }
927 Value::Object(out)
928 }
929 other => other,
930 }
931}
932
933#[cfg(feature = "transform-drop")]
936fn drop_fields(value: Value, fields: &[String]) -> Value {
937 match value {
938 Value::Object(mut map) => {
939 for f in fields {
940 map.remove(f);
941 }
942 Value::Object(map)
943 }
944 other => other,
945 }
946}
947
948#[cfg(feature = "transform-set")]
951fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
952 match value {
953 Value::Object(mut map) => {
954 for (k, v) in values {
955 map.insert(k.clone(), v.clone());
956 }
957 Value::Object(map)
958 }
959 other => other,
960 }
961}
962
963#[cfg(feature = "transform-rename-field")]
966fn rename_field(value: Value, fields: &HashMap<String, String>) -> Result<Value, FaucetError> {
967 match value {
968 Value::Object(mut map) => {
969 for (from, to) in fields {
970 if from == to {
971 continue;
972 }
973 if let Some(v) = map.remove(from) {
974 if map.contains_key(to) {
978 return Err(FaucetError::Transform(format!(
979 "rename_field: target key '{to}' already exists on the record \
980 (renaming from '{from}')"
981 )));
982 }
983 map.insert(to.clone(), v);
984 }
985 }
986 Ok(Value::Object(map))
987 }
988 other => Ok(other),
989 }
990}
991
992#[cfg(feature = "transform-cast")]
995fn cast_fields(
996 value: Value,
997 fields: &HashMap<String, CastType>,
998 on_error: CastOnError,
999) -> Result<Value, FaucetError> {
1000 match value {
1001 Value::Object(mut map) => {
1002 for (field, target) in fields {
1003 let Some(current) = map.get(field) else {
1004 continue;
1005 };
1006 match cast_value(current, *target) {
1007 Ok(new_val) => {
1008 map.insert(field.clone(), new_val);
1009 }
1010 Err(msg) => match on_error {
1011 CastOnError::Error => {
1012 return Err(FaucetError::Transform(format!(
1013 "cast: field '{field}' to {target:?} failed: {msg}"
1014 )));
1015 }
1016 CastOnError::Null => {
1017 map.insert(field.clone(), Value::Null);
1018 }
1019 CastOnError::Skip => { }
1020 },
1021 }
1022 }
1023 Ok(Value::Object(map))
1024 }
1025 other => Ok(other),
1026 }
1027}
1028
1029#[cfg(feature = "transform-cast")]
1032fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1033 match target {
1034 CastType::Int => match v {
1035 Value::Number(n) => {
1036 if let Some(i) = n.as_i64() {
1037 return Ok(Value::Number(i.into()));
1038 }
1039 match n.as_f64() {
1046 Some(f)
1047 if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1048 {
1049 Ok(Value::Number((f as i64).into()))
1050 }
1051 Some(f) => Err(format!(
1052 "float '{f}' is not a whole number representable as i64"
1053 )),
1054 None => Err(format!("number '{n}' is not representable as i64")),
1055 }
1056 }
1057 Value::String(s) => s
1058 .trim()
1059 .parse::<i64>()
1060 .map(|i| Value::Number(i.into()))
1061 .map_err(|e| format!("'{s}' is not an integer: {e}")),
1062 Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1063 Value::Null => Err("null cannot be cast to int".to_owned()),
1064 Value::Array(_) | Value::Object(_) => {
1065 Err("composite values cannot be cast to int".to_owned())
1066 }
1067 },
1068 CastType::Float => match v {
1069 Value::Number(n) => n
1070 .as_f64()
1071 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1072 .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1073 Value::String(s) => s
1074 .trim()
1075 .parse::<f64>()
1076 .ok()
1077 .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1078 .ok_or_else(|| format!("'{s}' is not a float")),
1079 Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1080 .map(Value::Number)
1081 .ok_or_else(|| "could not encode bool as f64".to_owned()),
1082 Value::Null => Err("null cannot be cast to float".to_owned()),
1083 Value::Array(_) | Value::Object(_) => {
1084 Err("composite values cannot be cast to float".to_owned())
1085 }
1086 },
1087 CastType::Bool => match v {
1088 Value::Bool(b) => Ok(Value::Bool(*b)),
1089 Value::Number(n) => {
1090 if let Some(i) = n.as_i64() {
1091 match i {
1092 0 => Ok(Value::Bool(false)),
1093 1 => Ok(Value::Bool(true)),
1094 _ => Err(format!("integer {i} is not 0 or 1")),
1095 }
1096 } else {
1097 Err(format!("number '{n}' is not 0 or 1"))
1098 }
1099 }
1100 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
1101 "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
1102 "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
1103 other => Err(format!("'{other}' is not a recognised boolean")),
1104 },
1105 Value::Null => Err("null cannot be cast to bool".to_owned()),
1106 Value::Array(_) | Value::Object(_) => {
1107 Err("composite values cannot be cast to bool".to_owned())
1108 }
1109 },
1110 CastType::String => match v {
1111 Value::String(s) => Ok(Value::String(s.clone())),
1112 Value::Number(n) => Ok(Value::String(n.to_string())),
1113 Value::Bool(b) => Ok(Value::String(b.to_string())),
1114 Value::Null => Err("null cannot be cast to string".to_owned()),
1115 Value::Array(_) | Value::Object(_) => {
1116 Err("composite values cannot be cast to string".to_owned())
1117 }
1118 },
1119 CastType::Timestamp => match v {
1120 Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
1121 .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
1122 .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
1123 other => Err(format!(
1124 "cannot cast {} to timestamp (expected RFC 3339 string)",
1125 value_type_name(other)
1126 )),
1127 },
1128 }
1129}
1130
1131#[cfg(feature = "transform-cast")]
1132fn value_type_name(v: &Value) -> &'static str {
1133 match v {
1134 Value::Null => "null",
1135 Value::Bool(_) => "bool",
1136 Value::Number(_) => "number",
1137 Value::String(_) => "string",
1138 Value::Array(_) => "array",
1139 Value::Object(_) => "object",
1140 }
1141}
1142
1143#[cfg(feature = "transform-redact")]
1146fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
1147 match value {
1148 Value::Object(mut map) => {
1149 for f in fields {
1150 if map.contains_key(f) {
1151 map.insert(f.clone(), mask.clone());
1152 }
1153 }
1154 Value::Object(map)
1155 }
1156 other => other,
1157 }
1158}
1159
1160#[cfg(feature = "transform-value-case")]
1163fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
1164 match value {
1165 Value::Object(mut map) => {
1166 for f in fields {
1167 if let Some(Value::String(s)) = map.get(f) {
1168 let new_s = match mode {
1169 ValueCaseMode::Lower => s.to_lowercase(),
1170 ValueCaseMode::Upper => s.to_uppercase(),
1171 ValueCaseMode::Trim => s.trim().to_owned(),
1172 };
1173 map.insert(f.clone(), Value::String(new_s));
1174 }
1175 }
1176 Value::Object(map)
1177 }
1178 other => other,
1179 }
1180}
1181
1182#[cfg(feature = "transform-spell-symbols")]
1193pub fn default_symbol_map() -> HashMap<String, String> {
1194 let pairs: &[(&str, &str)] = &[
1195 ("%", "percent"),
1196 ("#", "number"),
1197 ("$", "dollar"),
1198 ("&", "and"),
1199 ("@", "at"),
1200 ("+", "plus"),
1201 ("*", "star"),
1202 ("=", "equals"),
1203 ("<", "lt"),
1204 (">", "gt"),
1205 ("/", "slash"),
1206 ("\\", "backslash"),
1207 ("|", "pipe"),
1208 ("^", "caret"),
1209 ("~", "tilde"),
1210 ];
1211 pairs
1212 .iter()
1213 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1214 .collect()
1215}
1216
1217#[cfg(feature = "transform-spell-symbols")]
1218fn spell_symbols(
1219 value: Value,
1220 replacements: &[(String, String)],
1221 separator: &str,
1222) -> Result<Value, FaucetError> {
1223 match value {
1224 Value::Object(map) => {
1225 let mut new_map = Map::with_capacity(map.len());
1226 for (k, v) in map {
1227 let new_k = spell_symbols_in_key(&k, replacements, separator);
1228 let new_v = spell_symbols(v, replacements, separator)?;
1229 if new_map.contains_key(&new_k) {
1233 return Err(FaucetError::Transform(format!(
1234 "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
1235 expand to the same name"
1236 )));
1237 }
1238 new_map.insert(new_k, new_v);
1239 }
1240 Ok(Value::Object(new_map))
1241 }
1242 Value::Array(arr) => {
1243 let mut out = Vec::with_capacity(arr.len());
1244 for v in arr {
1245 out.push(spell_symbols(v, replacements, separator)?);
1246 }
1247 Ok(Value::Array(out))
1248 }
1249 other => Ok(other),
1250 }
1251}
1252
1253#[cfg(feature = "transform-spell-symbols")]
1257fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
1258 let bytes = key.as_bytes();
1262 let mut out = String::with_capacity(key.len());
1263 let mut i = 0;
1264 while i < bytes.len() {
1265 let mut matched = false;
1266 for (from, to) in replacements {
1267 let f = from.as_bytes();
1268 if !f.is_empty() && bytes[i..].starts_with(f) {
1269 out.push_str(separator);
1270 out.push_str(to);
1271 out.push_str(separator);
1272 i += f.len();
1273 matched = true;
1274 break;
1275 }
1276 }
1277 if !matched {
1278 let ch = key[i..]
1281 .chars()
1282 .next()
1283 .expect("non-empty slice yields at least one char");
1284 out.push(ch);
1285 i += ch.len_utf8();
1286 }
1287 }
1288 out
1289}
1290
1291#[cfg(test)]
1294mod tests {
1295 use super::*;
1296 use serde_json::json;
1297
1298 fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
1303 super::apply_all(record, transforms).expect("transform should succeed in this test")
1304 }
1305
1306 fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
1307 transforms.iter().map(|t| compile(t).unwrap()).collect()
1308 }
1309
1310 #[test]
1313 fn test_custom_adds_field() {
1314 let record = json!({"id": 1});
1315 let result = apply_all(
1316 record,
1317 &compiled(&[RecordTransform::custom(|mut v| {
1318 if let Value::Object(ref mut m) = v {
1319 m.insert("added".to_string(), json!(true));
1320 }
1321 v
1322 })]),
1323 );
1324 assert_eq!(result["id"], 1);
1325 assert_eq!(result["added"], true);
1326 }
1327
1328 #[test]
1329 fn test_custom_removes_field() {
1330 let record = json!({"id": 1, "secret": "drop_me"});
1331 let result = apply_all(
1332 record,
1333 &compiled(&[RecordTransform::custom(|mut v| {
1334 if let Value::Object(ref mut m) = v {
1335 m.remove("secret");
1336 }
1337 v
1338 })]),
1339 );
1340 assert_eq!(result["id"], 1);
1341 assert!(result.get("secret").is_none());
1342 }
1343
1344 #[test]
1345 fn test_no_transforms_is_identity() {
1346 let record = json!({"id": 1, "name": "Alice"});
1347 let result = apply_all(record.clone(), &[]);
1348 assert_eq!(result, record);
1349 }
1350
1351 #[cfg(feature = "transform-flatten")]
1354 #[test]
1355 fn test_flatten_nested_object() {
1356 let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
1357 let result = apply_all(
1358 record,
1359 &compiled(&[RecordTransform::Flatten {
1360 separator: "__".into(),
1361 }]),
1362 );
1363 assert_eq!(result["a__b"], 1);
1364 assert_eq!(result["a__c__d"], 2);
1365 assert_eq!(result["e"], 3);
1366 assert!(result.get("a").is_none(), "nested key should be removed");
1367 }
1368
1369 #[cfg(feature = "transform-flatten")]
1370 #[test]
1371 fn test_flatten_leaves_arrays_intact() {
1372 let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
1373 let result = apply_all(
1374 record,
1375 &compiled(&[RecordTransform::Flatten {
1376 separator: ".".into(),
1377 }]),
1378 );
1379 assert_eq!(result["tags"], json!(["rust", "api"]));
1380 assert_eq!(result["meta.count"], 2);
1381 }
1382
1383 #[cfg(feature = "transform-flatten")]
1384 #[test]
1385 fn test_flatten_already_flat() {
1386 let record = json!({"id": 1, "name": "Alice"});
1387 let result = apply_all(
1388 record.clone(),
1389 &compiled(&[RecordTransform::Flatten {
1390 separator: "__".into(),
1391 }]),
1392 );
1393 assert_eq!(result, record);
1394 }
1395
1396 #[cfg(feature = "transform-flatten")]
1397 #[test]
1398 fn test_flatten_empty_separator() {
1399 let record = json!({"a": {"b": 1}});
1400 let result = apply_all(
1401 record,
1402 &compiled(&[RecordTransform::Flatten {
1403 separator: "".into(),
1404 }]),
1405 );
1406 assert_eq!(result["ab"], 1);
1407 }
1408
1409 #[cfg(feature = "transform-rename-keys")]
1412 #[test]
1413 fn test_rename_keys_strips_prefix() {
1414 let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
1415 let result = apply_all(
1416 record,
1417 &compiled(&[RecordTransform::RenameKeys {
1418 pattern: r"^_prefix_".into(),
1419 replacement: "".into(),
1420 }]),
1421 );
1422 assert_eq!(result["id"], 1);
1423 assert_eq!(result["name"], "Alice");
1424 }
1425
1426 #[cfg(feature = "transform-rename-keys")]
1427 #[test]
1428 fn test_rename_keys_uppercase_to_placeholder() {
1429 let record = json!({"OUTER": {"INNER": 42}});
1430 let result = apply_all(
1431 record,
1432 &compiled(&[RecordTransform::RenameKeys {
1433 pattern: r"[A-Z]+".into(),
1434 replacement: "x".into(),
1435 }]),
1436 );
1437 assert_eq!(result["x"]["x"], 42);
1438 }
1439
1440 #[cfg(feature = "transform-rename-keys")]
1441 #[test]
1442 fn test_rename_keys_in_array_elements() {
1443 let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
1444 let result = apply_all(
1445 record,
1446 &compiled(&[RecordTransform::RenameKeys {
1447 pattern: r"KEY".into(),
1448 replacement: "key".into(),
1449 }]),
1450 );
1451 assert_eq!(result["items"][0]["key"], 1);
1452 assert_eq!(result["items"][1]["key"], 2);
1453 }
1454
1455 #[cfg(feature = "transform-rename-keys")]
1456 #[test]
1457 fn test_rename_keys_invalid_regex_errors_at_compile() {
1458 let err = compile(&RecordTransform::RenameKeys {
1459 pattern: "[invalid".into(),
1460 replacement: "".into(),
1461 });
1462 assert!(err.is_err());
1463 assert!(matches!(err, Err(FaucetError::Transform(_))));
1464 }
1465
1466 #[cfg(feature = "transform-rename-keys")]
1467 #[test]
1468 fn test_rename_keys_chained() {
1469 let record = json!({"__camelCase__": 1});
1470 let result = apply_all(
1471 record,
1472 &compiled(&[
1473 RecordTransform::RenameKeys {
1474 pattern: r"^_+|_+$".into(),
1475 replacement: "".into(),
1476 },
1477 RecordTransform::RenameKeys {
1478 pattern: r"[A-Z]".into(),
1479 replacement: "_".into(),
1480 },
1481 ]),
1482 );
1483 let key = result.as_object().unwrap().keys().next().unwrap().clone();
1484 assert_eq!(key, "camel_ase");
1485 }
1486
1487 #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
1490 #[test]
1491 fn test_keys_case_then_flatten() {
1492 let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
1493 let result = apply_all(
1494 record,
1495 &compiled(&[
1496 RecordTransform::KeysCase {
1497 mode: KeyCaseMode::Snake,
1498 },
1499 RecordTransform::Flatten {
1500 separator: "_".into(),
1501 },
1502 ]),
1503 );
1504 assert_eq!(result["user_info_first_name"], "Alice");
1505 assert_eq!(result["user_info_last_name"], "Smith");
1506 }
1507
1508 #[test]
1509 fn test_custom_chained_with_builtin() {
1510 let record = json!({"id": 1, "raw_value": 100});
1512 let result = apply_all(
1513 record,
1514 &compiled(&[
1515 RecordTransform::custom(|mut v| {
1517 if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
1518 && let Value::Object(ref mut m) = v
1519 {
1520 m.insert("raw_value".to_string(), json!(n * 2));
1521 }
1522 v
1523 }),
1524 RecordTransform::custom(|mut v| {
1526 if let Value::Object(ref mut m) = v
1527 && let Some(val) = m.remove("raw_value")
1528 {
1529 m.insert("value".to_string(), val);
1530 }
1531 v
1532 }),
1533 ]),
1534 );
1535 assert_eq!(result["id"], 1);
1536 assert_eq!(result["value"], 200);
1537 assert!(result.get("raw_value").is_none());
1538 }
1539
1540 #[cfg(feature = "transform-flatten")]
1543 #[test]
1544 fn flatten_key_collision_errors() {
1545 let record = json!({"a__b": 1, "a": {"b": 2}});
1547 let err = super::apply_all(
1548 record,
1549 &compiled(&[RecordTransform::Flatten {
1550 separator: "__".into(),
1551 }]),
1552 )
1553 .expect_err("colliding flattened keys must error, not drop a value");
1554 assert!(matches!(err, FaucetError::Transform(_)));
1555 assert!(format!("{err}").contains("a__b"), "{err}");
1556 }
1557
1558 #[cfg(feature = "transform-select")]
1561 #[test]
1562 fn select_keeps_only_listed_fields() {
1563 let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
1564 let result = apply_all(
1565 record,
1566 &compiled(&[RecordTransform::Select {
1567 fields: vec!["id".into(), "name".into()],
1568 }]),
1569 );
1570 assert_eq!(result["id"], 1);
1571 assert_eq!(result["name"], "Alice");
1572 assert!(result.get("secret").is_none());
1573 }
1574
1575 #[cfg(feature = "transform-select")]
1576 #[test]
1577 fn select_missing_field_is_no_op() {
1578 let record = json!({"id": 1});
1580 let result = apply_all(
1581 record,
1582 &compiled(&[RecordTransform::Select {
1583 fields: vec!["id".into(), "missing".into()],
1584 }]),
1585 );
1586 assert_eq!(result["id"], 1);
1587 assert!(result.get("missing").is_none());
1588 }
1589
1590 #[cfg(feature = "transform-select")]
1591 #[test]
1592 fn select_passes_through_non_object() {
1593 let record = json!([1, 2, 3]);
1594 let result = apply_all(
1595 record.clone(),
1596 &compiled(&[RecordTransform::Select {
1597 fields: vec!["id".into()],
1598 }]),
1599 );
1600 assert_eq!(result, record);
1601 }
1602
1603 #[cfg(feature = "transform-drop")]
1606 #[test]
1607 fn drop_removes_listed_fields() {
1608 let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
1609 let result = apply_all(
1610 record,
1611 &compiled(&[RecordTransform::Drop {
1612 fields: vec!["ssn".into()],
1613 }]),
1614 );
1615 assert_eq!(result["id"], 1);
1616 assert_eq!(result["name"], "Alice");
1617 assert!(result.get("ssn").is_none());
1618 }
1619
1620 #[cfg(feature = "transform-drop")]
1621 #[test]
1622 fn drop_missing_field_is_no_op() {
1623 let record = json!({"id": 1});
1624 let result = apply_all(
1625 record,
1626 &compiled(&[RecordTransform::Drop {
1627 fields: vec!["missing".into()],
1628 }]),
1629 );
1630 assert_eq!(result["id"], 1);
1631 }
1632
1633 #[cfg(feature = "transform-set")]
1636 #[test]
1637 fn set_inserts_new_fields() {
1638 let record = json!({"id": 1});
1639 let mut values = Map::new();
1640 values.insert("_source".into(), json!("api"));
1641 values.insert("ingested_at".into(), json!("2026-01-01"));
1642 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1643 assert_eq!(result["id"], 1);
1644 assert_eq!(result["_source"], "api");
1645 assert_eq!(result["ingested_at"], "2026-01-01");
1646 }
1647
1648 #[cfg(feature = "transform-set")]
1649 #[test]
1650 fn set_overwrites_existing_field() {
1651 let record = json!({"_source": "old", "id": 1});
1652 let mut values = Map::new();
1653 values.insert("_source".into(), json!("new"));
1654 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1655 assert_eq!(result["_source"], "new");
1656 assert_eq!(result["id"], 1);
1657 }
1658
1659 #[cfg(feature = "transform-set")]
1660 #[test]
1661 fn set_supports_any_json_value() {
1662 let record = json!({});
1663 let mut values = Map::new();
1664 values.insert("n".into(), json!(42));
1665 values.insert("b".into(), json!(true));
1666 values.insert("arr".into(), json!([1, 2]));
1667 values.insert("obj".into(), json!({"k": "v"}));
1668 values.insert("null".into(), Value::Null);
1669 let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1670 assert_eq!(result["n"], 42);
1671 assert_eq!(result["b"], true);
1672 assert_eq!(result["arr"], json!([1, 2]));
1673 assert_eq!(result["obj"]["k"], "v");
1674 assert_eq!(result["null"], Value::Null);
1675 }
1676
1677 #[cfg(feature = "transform-rename-field")]
1680 #[test]
1681 fn rename_field_renames_exact_key() {
1682 let record = json!({"old_name": 1, "keep": 2});
1683 let mut fields = HashMap::new();
1684 fields.insert("old_name".to_owned(), "new_name".to_owned());
1685 let result = apply_all(
1686 record,
1687 &compiled(&[RecordTransform::RenameField { fields }]),
1688 );
1689 assert_eq!(result["new_name"], 1);
1690 assert_eq!(result["keep"], 2);
1691 assert!(result.get("old_name").is_none());
1692 }
1693
1694 #[cfg(feature = "transform-rename-field")]
1695 #[test]
1696 fn rename_field_missing_source_is_no_op() {
1697 let record = json!({"id": 1});
1698 let mut fields = HashMap::new();
1699 fields.insert("missing".to_owned(), "renamed".to_owned());
1700 let result = apply_all(
1701 record,
1702 &compiled(&[RecordTransform::RenameField { fields }]),
1703 );
1704 assert_eq!(result["id"], 1);
1705 assert!(result.get("renamed").is_none());
1706 }
1707
1708 #[cfg(feature = "transform-rename-field")]
1709 #[test]
1710 fn rename_field_target_collision_errors() {
1711 let record = json!({"a": 1, "b": 2});
1712 let mut fields = HashMap::new();
1713 fields.insert("a".to_owned(), "b".to_owned());
1714 let err = super::apply_all(
1715 record,
1716 &compiled(&[RecordTransform::RenameField { fields }]),
1717 )
1718 .expect_err("collision must error, not overwrite");
1719 assert!(matches!(err, FaucetError::Transform(_)));
1720 assert!(format!("{err}").contains("'b'"), "{err}");
1721 }
1722
1723 #[cfg(feature = "transform-cast")]
1726 fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
1727 let mut fields = HashMap::new();
1728 fields.insert(field.to_owned(), ty);
1729 vec![RecordTransform::Cast { fields, on_error }]
1730 }
1731
1732 #[cfg(feature = "transform-cast")]
1733 #[test]
1734 fn cast_string_to_int() {
1735 let record = json!({"age": "42"});
1736 let result = apply_all(
1737 record,
1738 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
1739 );
1740 assert_eq!(result["age"], 42);
1741 }
1742
1743 #[cfg(feature = "transform-cast")]
1744 #[test]
1745 fn cast_whole_number_float_to_int_succeeds() {
1746 let record = json!({"n": 5.0});
1748 let result = apply_all(
1749 record,
1750 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1751 );
1752 assert_eq!(result["n"], 5);
1753 }
1754
1755 #[cfg(feature = "transform-cast")]
1756 #[test]
1757 fn cast_fractional_float_to_int_errors_under_on_error_error() {
1758 let record = json!({"n": 3.9});
1760 let err = super::apply_all(
1761 record,
1762 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1763 )
1764 .expect_err("a fractional float must not silently truncate to int");
1765 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
1766 }
1767
1768 #[cfg(feature = "transform-cast")]
1769 #[test]
1770 fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
1771 let record = json!({"n": 1e30});
1773 let err = super::apply_all(
1774 record,
1775 &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1776 )
1777 .expect_err("an out-of-range float must not silently saturate to i64::MAX");
1778 assert!(matches!(err, FaucetError::Transform(_)), "{err}");
1779 }
1780
1781 #[cfg(feature = "transform-cast")]
1782 #[test]
1783 fn cast_fractional_float_to_int_nulls_under_on_error_null() {
1784 let record = json!({"n": 3.9});
1785 let result = apply_all(
1786 record,
1787 &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
1788 );
1789 assert_eq!(result["n"], Value::Null);
1790 }
1791
1792 #[cfg(feature = "transform-cast")]
1793 #[test]
1794 fn cast_string_to_float() {
1795 let record = json!({"price": "9.99"});
1796 let result = apply_all(
1797 record,
1798 &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
1799 );
1800 assert_eq!(result["price"], 9.99);
1801 }
1802
1803 #[cfg(feature = "transform-cast")]
1804 #[test]
1805 fn cast_string_to_bool() {
1806 for input in ["true", "TRUE", "1", "yes"] {
1807 let record = json!({"flag": input});
1808 let result = apply_all(
1809 record,
1810 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
1811 );
1812 assert_eq!(result["flag"], true, "input was {input:?}");
1813 }
1814 for input in ["false", "0", "no"] {
1815 let record = json!({"flag": input});
1816 let result = apply_all(
1817 record,
1818 &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
1819 );
1820 assert_eq!(result["flag"], false, "input was {input:?}");
1821 }
1822 }
1823
1824 #[cfg(feature = "transform-cast")]
1825 #[test]
1826 fn cast_number_to_string() {
1827 let record = json!({"id": 42});
1828 let result = apply_all(
1829 record,
1830 &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
1831 );
1832 assert_eq!(result["id"], "42");
1833 }
1834
1835 #[cfg(feature = "transform-cast")]
1836 #[test]
1837 fn cast_string_to_timestamp_normalises() {
1838 let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
1839 let result = apply_all(
1840 record,
1841 &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
1842 );
1843 assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
1845 }
1846
1847 #[cfg(feature = "transform-cast")]
1848 #[test]
1849 fn cast_on_error_error_propagates() {
1850 let record = json!({"age": "not a number"});
1851 let err = super::apply_all(
1852 record,
1853 &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
1854 )
1855 .expect_err("uncastable value must error under on_error=error");
1856 assert!(matches!(err, FaucetError::Transform(_)));
1857 assert!(format!("{err}").contains("'age'"), "{err}");
1858 }
1859
1860 #[cfg(feature = "transform-cast")]
1861 #[test]
1862 fn cast_on_error_null_replaces() {
1863 let record = json!({"age": "not a number"});
1864 let result = apply_all(
1865 record,
1866 &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
1867 );
1868 assert_eq!(result["age"], Value::Null);
1869 }
1870
1871 #[cfg(feature = "transform-cast")]
1872 #[test]
1873 fn cast_on_error_skip_leaves_value() {
1874 let record = json!({"age": "not a number"});
1875 let result = apply_all(
1876 record,
1877 &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
1878 );
1879 assert_eq!(result["age"], "not a number");
1880 }
1881
1882 #[cfg(feature = "transform-cast")]
1883 #[test]
1884 fn cast_missing_field_is_no_op() {
1885 let record = json!({"id": 1});
1886 let result = apply_all(
1887 record,
1888 &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
1889 );
1890 assert_eq!(result["id"], 1);
1891 assert!(result.get("missing").is_none());
1892 }
1893
1894 #[cfg(feature = "transform-redact")]
1897 #[test]
1898 fn redact_replaces_value_with_mask() {
1899 let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
1900 let result = apply_all(
1901 record,
1902 &compiled(&[RecordTransform::Redact {
1903 fields: vec!["ssn".into(), "email".into()],
1904 mask: json!("***"),
1905 }]),
1906 );
1907 assert_eq!(result["id"], 1);
1908 assert_eq!(result["ssn"], "***");
1909 assert_eq!(result["email"], "***");
1910 }
1911
1912 #[cfg(feature = "transform-redact")]
1913 #[test]
1914 fn redact_missing_field_does_not_insert_mask() {
1915 let record = json!({"id": 1});
1916 let result = apply_all(
1917 record,
1918 &compiled(&[RecordTransform::Redact {
1919 fields: vec!["ssn".into()],
1920 mask: json!("***"),
1921 }]),
1922 );
1923 assert_eq!(result["id"], 1);
1924 assert!(result.get("ssn").is_none());
1925 }
1926
1927 #[cfg(feature = "transform-value-case")]
1930 #[test]
1931 fn value_case_lower() {
1932 let record = json!({"email": "User@Example.COM", "id": 1});
1933 let result = apply_all(
1934 record,
1935 &compiled(&[RecordTransform::ValueCase {
1936 fields: vec!["email".into()],
1937 mode: ValueCaseMode::Lower,
1938 }]),
1939 );
1940 assert_eq!(result["email"], "user@example.com");
1941 assert_eq!(result["id"], 1);
1942 }
1943
1944 #[cfg(feature = "transform-value-case")]
1945 #[test]
1946 fn value_case_upper() {
1947 let record = json!({"code": "abc"});
1948 let result = apply_all(
1949 record,
1950 &compiled(&[RecordTransform::ValueCase {
1951 fields: vec!["code".into()],
1952 mode: ValueCaseMode::Upper,
1953 }]),
1954 );
1955 assert_eq!(result["code"], "ABC");
1956 }
1957
1958 #[cfg(feature = "transform-value-case")]
1959 #[test]
1960 fn value_case_trim() {
1961 let record = json!({"name": " Alice "});
1962 let result = apply_all(
1963 record,
1964 &compiled(&[RecordTransform::ValueCase {
1965 fields: vec!["name".into()],
1966 mode: ValueCaseMode::Trim,
1967 }]),
1968 );
1969 assert_eq!(result["name"], "Alice");
1970 }
1971
1972 #[cfg(feature = "transform-value-case")]
1973 #[test]
1974 fn value_case_passes_non_string_through() {
1975 let record = json!({"id": 42});
1976 let result = apply_all(
1977 record,
1978 &compiled(&[RecordTransform::ValueCase {
1979 fields: vec!["id".into()],
1980 mode: ValueCaseMode::Upper,
1981 }]),
1982 );
1983 assert_eq!(result["id"], 42);
1984 }
1985
1986 #[cfg(feature = "transform-spell-symbols")]
1989 fn spell_default() -> Vec<RecordTransform> {
1990 vec![RecordTransform::SpellSymbols {
1991 extra: HashMap::new(),
1992 separator: " ".into(),
1993 }]
1994 }
1995
1996 #[cfg(feature = "transform-spell-symbols")]
1997 #[test]
1998 fn spell_symbols_replaces_common_symbols() {
1999 let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
2000 let result = apply_all(record, &compiled(&spell_default()));
2001 assert!(result.get(" percent sold").is_some());
2004 assert!(result.get("C number course").is_some());
2005 assert!(result.get(" dollar amount").is_some());
2006 }
2007
2008 #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
2009 #[test]
2010 fn spell_symbols_then_keys_case_pipeline() {
2011 let record = json!({"% sold": 10, "C# courses": 20});
2012 let result = super::apply_all(
2013 record,
2014 &compiled(&[
2015 RecordTransform::SpellSymbols {
2016 extra: HashMap::new(),
2017 separator: " ".into(),
2018 },
2019 RecordTransform::KeysCase {
2020 mode: KeyCaseMode::Snake,
2021 },
2022 ]),
2023 )
2024 .expect("pipeline must succeed");
2025 assert_eq!(result["percent_sold"], 10);
2026 assert_eq!(result["c_number_courses"], 20);
2027 }
2028
2029 #[cfg(feature = "transform-spell-symbols")]
2030 #[test]
2031 fn spell_symbols_extra_overrides_defaults() {
2032 let mut extra = HashMap::new();
2033 extra.insert("#".to_owned(), "hash".to_owned());
2034 extra.insert("©".to_owned(), "copyright".to_owned());
2035 let record = json!({"#tag": 1, "©2026": 2});
2036 let result = apply_all(
2037 record,
2038 &compiled(&[RecordTransform::SpellSymbols {
2039 extra,
2040 separator: " ".into(),
2041 }]),
2042 );
2043 assert!(result.get(" hash tag").is_some());
2045 assert!(result.get(" copyright 2026").is_some());
2047 }
2048
2049 #[cfg(feature = "transform-spell-symbols")]
2050 #[test]
2051 fn spell_symbols_longest_match_wins() {
2052 let mut extra = HashMap::new();
2054 extra.insert("<=".to_owned(), "lte".to_owned());
2055 let record = json!({"a<=b": 1});
2056 let result = apply_all(
2057 record,
2058 &compiled(&[RecordTransform::SpellSymbols {
2059 extra,
2060 separator: " ".into(),
2061 }]),
2062 );
2063 assert!(result.get("a lte b").is_some());
2064 assert!(result.get("a lt = b").is_none());
2066 }
2067
2068 #[cfg(feature = "transform-spell-symbols")]
2069 #[test]
2070 fn spell_symbols_recursive_into_objects_and_arrays() {
2071 let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
2072 let result = apply_all(record, &compiled(&spell_default()));
2073 let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
2074 assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
2075 let inner = &result[&outer_key];
2076 let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
2077 assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
2078 let deep = &inner[&inner_key][0];
2079 let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
2080 assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
2081 }
2082
2083 #[cfg(feature = "transform-spell-symbols")]
2084 #[test]
2085 fn spell_symbols_key_collision_errors() {
2086 let record = json!({"%": 1, "percent": 2});
2088 let err = super::apply_all(
2089 record,
2090 &compiled(&[RecordTransform::SpellSymbols {
2091 extra: HashMap::new(),
2092 separator: "".into(),
2093 }]),
2094 )
2095 .expect_err("colliding spelled keys must error, not drop a value");
2096 assert!(matches!(err, FaucetError::Transform(_)));
2097 assert!(format!("{err}").contains("percent"), "{err}");
2098 }
2099
2100 #[cfg(feature = "transform-keys-case")]
2103 fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
2104 vec![RecordTransform::KeysCase { mode }]
2105 }
2106
2107 #[cfg(feature = "transform-keys-case")]
2108 #[test]
2109 fn keys_case_snake() {
2110 let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
2111 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2112 assert_eq!(result["first_name"], 1);
2113 assert_eq!(result["last_name"], 2);
2114 assert_eq!(result["id"], 3);
2115 }
2116
2117 #[cfg(feature = "transform-keys-case")]
2118 #[test]
2119 fn keys_case_camel_from_various_inputs() {
2120 let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
2122 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
2123 assert_eq!(result["firstName"], 1);
2124 assert_eq!(result["userId"], 2);
2125 assert_eq!(result["kebabCase"], 3);
2126 assert_eq!(result["pascalCase"], 4);
2127 }
2128
2129 #[cfg(feature = "transform-keys-case")]
2130 #[test]
2131 fn keys_case_pascal() {
2132 let record = json!({"first_name": 1, "second name": 2});
2133 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
2134 assert_eq!(result["FirstName"], 1);
2135 assert_eq!(result["SecondName"], 2);
2136 }
2137
2138 #[cfg(feature = "transform-keys-case")]
2139 #[test]
2140 fn keys_case_kebab() {
2141 let record = json!({"firstName": 1, "second_name": 2});
2142 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
2143 assert_eq!(result["first-name"], 1);
2144 assert_eq!(result["second-name"], 2);
2145 }
2146
2147 #[cfg(feature = "transform-keys-case")]
2148 #[test]
2149 fn keys_case_screaming_snake() {
2150 let record = json!({"firstName": 1, "second name": 2});
2151 let result = apply_all(
2152 record,
2153 &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
2154 );
2155 assert_eq!(result["FIRST_NAME"], 1);
2156 assert_eq!(result["SECOND_NAME"], 2);
2157 }
2158
2159 #[cfg(feature = "transform-keys-case")]
2160 #[test]
2161 fn keys_case_recursive_into_nested() {
2162 let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
2163 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2164 assert_eq!(result["user_info"]["first_name"], "Alice");
2165 assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
2166 }
2167
2168 #[cfg(feature = "transform-keys-case")]
2169 #[test]
2170 fn keys_case_collision_errors() {
2171 let record = json!({"firstName": 1, "first_name": 2});
2173 let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
2174 .expect_err("colliding re-cased keys must error, not drop a value");
2175 assert!(matches!(err, FaucetError::Transform(_)));
2176 assert!(format!("{err}").contains("first_name"), "{err}");
2177 }
2178
2179 #[cfg(feature = "transform-keys-case")]
2180 #[test]
2181 fn keys_case_all_symbol_key_kept_as_is() {
2182 let record = json!({"!@#": 1, "id": 2});
2185 let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2186 assert_eq!(result["!@#"], 1);
2187 assert_eq!(result["id"], 2);
2188 }
2189
2190 #[cfg(feature = "transform-keys-case")]
2191 #[test]
2192 fn keys_case_idempotent_in_target_mode() {
2193 let record = json!({"first_name": 1});
2196 let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2197 let twice = apply_all(
2198 once.clone(),
2199 &compiled(&keys_case_specs(KeyCaseMode::Snake)),
2200 );
2201 assert_eq!(once, twice);
2202 }
2203
2204 #[cfg(feature = "transform-spell-symbols")]
2205 #[test]
2206 fn spell_symbols_handles_unicode_keys() {
2207 let record = json!({"café%": 1});
2209 let result = apply_all(record, &compiled(&spell_default()));
2210 let key = result.as_object().unwrap().keys().next().unwrap().clone();
2211 assert!(key.contains("café"), "key was {key:?}");
2212 assert!(key.contains("percent"), "key was {key:?}");
2213 }
2214}