Skip to main content

faucet_core/
transform.rs

1//! Record transformation pipeline.
2//!
3//! ## Built-in transforms (optional Cargo features)
4//!
5//! | Variant | Feature flag | Default |
6//! |---------|-------------|---------|
7//! | [`RecordTransform::Flatten`] | `transform-flatten` | enabled |
8//! | [`RecordTransform::RenameKeys`] | `transform-rename-keys` | enabled |
9//! | [`RecordTransform::KeysCase`] | `transform-keys-case` | enabled |
10//! | [`RecordTransform::Select`] | `transform-select` | off |
11//! | [`RecordTransform::Drop`] | `transform-drop` | off |
12//! | [`RecordTransform::Set`] | `transform-set` | off |
13//! | [`RecordTransform::RenameField`] | `transform-rename-field` | off |
14//! | [`RecordTransform::Cast`] | `transform-cast` | off |
15//! | [`RecordTransform::Redact`] | `transform-redact` | off |
16//! | [`RecordTransform::ValueCase`] | `transform-value-case` | off |
17//! | [`RecordTransform::SpellSymbols`] | `transform-spell-symbols` | off |
18//!
19//! The `transforms` aggregate feature pulls in every variant above.
20//!
21//! Disable a transform (and its dependencies) by opting out of its feature:
22//!
23//! ```toml
24//! [dependencies]
25//! faucet-stream = { version = "*", default-features = false,
26//!                   features = ["transform-flatten"] }
27//! ```
28//!
29//! ## Stage-level transforms (filter / explode)
30//!
31//! `filter` and `explode` are not `RecordTransform` variants — they live as
32//! [`crate::stage::TransformStage::Filter`] / `TransformStage::Explode` because
33//! they may emit 0 or N records per input. Their feature flags are
34//! `transform-filter` and `transform-explode`. See the `stage` module for
35//! details.
36//!
37//! ## Custom transforms
38//!
39//! [`RecordTransform::Custom`] is always available regardless of features.
40//! Pass any closure or function pointer via [`RecordTransform::custom`].
41
42use 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// ── Support enums for the new transforms ──────────────────────────────────────
66
67/// Target type for [`RecordTransform::Cast`].
68///
69/// Coerces a JSON value to the requested concrete type.  `Timestamp` parses
70/// RFC 3339 / ISO 8601 strings and normalises them back to RFC 3339 (so
71/// `"2026-05-28T00:00:00Z"` round-trips unchanged but `"2026-05-28T00:00:00+00:00"`
72/// becomes the canonical form).
73#[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    /// 64-bit signed integer (`i64`).
80    Int,
81    /// 64-bit float (`f64`).
82    Float,
83    /// Boolean.  Accepts `true`/`false`/`1`/`0` (case-insensitive) when the
84    /// source value is a string.
85    Bool,
86    /// String.  Numbers and booleans are stringified via `to_string()`.
87    String,
88    /// RFC 3339 timestamp, returned as a normalised RFC 3339 string.
89    Timestamp,
90}
91
92/// Failure policy for [`RecordTransform::Cast`].  Default: `Error`.
93#[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    /// Return [`FaucetError::Transform`] when a value cannot be cast.
108    #[default]
109    Error,
110    /// Replace the un-castable value with [`Value::Null`].
111    Null,
112    /// Leave the un-castable value unchanged in the record.
113    Skip,
114}
115
116/// Output convention for [`RecordTransform::KeysCase`].
117///
118/// The transform tokenises each key on whitespace, `_`, `-`, dropped
119/// punctuation, and lower→upper transitions, then re-joins the tokens in
120/// the requested style.
121#[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_case` — words separated by `_`, all lowercase.
128    Snake,
129    /// `camelCase` — first token lowercase, subsequent tokens capitalised,
130    /// no separator.
131    Camel,
132    /// `PascalCase` — every token capitalised, no separator.
133    Pascal,
134    /// `kebab-case` — words separated by `-`, all lowercase.
135    Kebab,
136    /// `SCREAMING_SNAKE_CASE` — words separated by `_`, all uppercase.
137    ScreamingSnake,
138}
139
140/// String-value casing mode for [`RecordTransform::ValueCase`].
141#[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    /// Lowercase the value.
148    Lower,
149    /// Uppercase the value.
150    Upper,
151    /// Trim leading/trailing whitespace from the value.
152    Trim,
153}
154
155// ── Public config-facing type ─────────────────────────────────────────────────
156
157/// A transformation applied to every record fetched by a source (e.g. the REST
158/// source's `RestStream`).
159///
160/// Transforms are applied in the order they are added via the owning source's
161/// configuration (e.g. `RestStreamConfig::add_transform`).
162///
163/// The three built-in variants are each guarded by a Cargo feature flag
164/// (all enabled by default — see module-level docs).
165/// [`RecordTransform::Custom`] is always available and accepts any closure.
166pub enum RecordTransform {
167    /// Flatten nested JSON objects into a single-level map.
168    ///
169    /// Nested key paths are joined with `separator`.  Arrays are left as-is.
170    ///
171    /// _Requires feature `transform-flatten` (default)._
172    ///
173    /// # Example
174    ///
175    /// ```text
176    /// {"user": {"id": 1, "addr": {"city": "NYC"}}}  →  (separator = "__")
177    /// {"user__id": 1, "user__addr__city": "NYC"}
178    /// ```
179    #[cfg(feature = "transform-flatten")]
180    Flatten { separator: String },
181
182    /// Apply a single regex substitution to every key in the record.
183    ///
184    /// Keys in nested objects and objects inside arrays are also renamed
185    /// recursively.  `pattern` is a Rust regex; `replacement` may reference
186    /// capture groups with `$1`, `${name}`, etc.  Chain multiple `RenameKeys`
187    /// transforms for multi-step pipelines.
188    ///
189    /// _Requires feature `transform-rename-keys` (default)._
190    ///
191    /// # Example
192    ///
193    /// ```text
194    /// pattern = r"^_sdc_", replacement = ""   →   strip "_sdc_" prefix
195    /// ```
196    #[cfg(feature = "transform-rename-keys")]
197    RenameKeys {
198        pattern: String,
199        replacement: String,
200    },
201
202    /// Re-case every key in the record according to `mode`.
203    ///
204    /// Tokenises each key on whitespace, `_`, `-`, dropped punctuation, and
205    /// lower→upper transitions, then re-joins in the requested convention.
206    /// Walks recursively into nested objects and arrays.  Two distinct keys
207    /// that re-case to the same name error rather than silently overwriting.
208    ///
209    /// _Requires feature `transform-keys-case` (default)._
210    ///
211    /// | Input          | `Snake`        | `Camel`       | `Pascal`     | `Kebab`        | `ScreamingSnake` |
212    /// |----------------|----------------|---------------|--------------|----------------|------------------|
213    /// | `"First Name"` | `"first_name"` | `"firstName"` | `"FirstName"`| `"first-name"` | `"FIRST_NAME"`   |
214    /// | `"last-name"`  | `"last_name"`  | `"lastName"`  | `"LastName"` | `"last-name"`  | `"LAST_NAME"`    |
215    /// | `"camelCase"`  | `"camel_case"` | `"camelCase"` | `"CamelCase"`| `"camel-case"` | `"CAMEL_CASE"`   |
216    #[cfg(feature = "transform-keys-case")]
217    KeysCase { mode: KeyCaseMode },
218
219    /// Keep only the listed top-level fields on each record; remove the rest.
220    ///
221    /// Missing fields are silently skipped (they don't introduce `null`s).
222    /// Non-object records pass through unchanged.
223    ///
224    /// _Requires feature `transform-select`._
225    #[cfg(feature = "transform-select")]
226    Select { fields: Vec<String> },
227
228    /// Remove the listed top-level fields from each record.
229    ///
230    /// Missing fields are silently skipped. Non-object records pass through.
231    ///
232    /// _Requires feature `transform-drop`._
233    #[cfg(feature = "transform-drop")]
234    Drop { fields: Vec<String> },
235
236    /// Insert or overwrite top-level fields on each record with constant values.
237    ///
238    /// Existing fields with the same name are overwritten. Non-object records
239    /// pass through unchanged.
240    ///
241    /// _Requires feature `transform-set`._
242    #[cfg(feature = "transform-set")]
243    Set { values: Map<String, Value> },
244
245    /// Exact-name rename of one or more top-level fields.
246    ///
247    /// Unlike [`RecordTransform::RenameKeys`] (regex, recursive), this only
248    /// touches exact top-level keys. Missing source fields are silently skipped.
249    /// If a target name already exists on the record, the rename errors rather
250    /// than silently overwriting.
251    ///
252    /// _Requires feature `transform-rename-field`._
253    #[cfg(feature = "transform-rename-field")]
254    RenameField {
255        /// Map of `old_name -> new_name`.
256        fields: HashMap<String, String>,
257    },
258
259    /// Coerce per-field types on each record.
260    ///
261    /// Each named field is converted to the matching [`CastType`]. The
262    /// [`CastOnError`] policy controls failure behaviour. Missing fields are
263    /// silently skipped (no `null`s introduced).
264    ///
265    /// _Requires feature `transform-cast`._
266    #[cfg(feature = "transform-cast")]
267    Cast {
268        fields: HashMap<String, CastType>,
269        on_error: CastOnError,
270    },
271
272    /// Replace each listed field's value with a constant mask.
273    ///
274    /// Missing fields are silently skipped (no mask inserted). Default mask is
275    /// `"***"` when constructed from CLI config.
276    ///
277    /// _Requires feature `transform-redact`._
278    #[cfg(feature = "transform-redact")]
279    Redact { fields: Vec<String>, mask: Value },
280
281    /// Lowercase / uppercase / trim string values on listed fields.
282    ///
283    /// Non-string field values pass through unchanged. Missing fields are
284    /// silently skipped.
285    ///
286    /// _Requires feature `transform-value-case`._
287    #[cfg(feature = "transform-value-case")]
288    ValueCase {
289        fields: Vec<String>,
290        mode: ValueCaseMode,
291    },
292
293    /// Recursively spell out symbols inside every key with their word
294    /// equivalents (`%` → `percent`, `#` → `number`, `$` → `dollar`, …).
295    ///
296    /// Built-in defaults cover the common ASCII symbols (see
297    /// [`default_symbol_map`]); `extra` adds or overrides entries.  Each
298    /// replacement is surrounded by `separator` (default `" "`) so a chained
299    /// [`RecordTransform::KeysCase`] picks up the word boundary.
300    /// Keys are walked recursively into nested objects and arrays, mirroring
301    /// the existing key-shape transforms.  Two distinct keys that collapse to
302    /// the same name error rather than silently overwriting.
303    ///
304    /// _Requires feature `transform-spell-symbols`._
305    ///
306    /// # Example
307    ///
308    /// ```text
309    /// {"% sold": 1, "C# courses": 2}
310    ///   →  (defaults, separator=" ")
311    /// {" percent  sold": 1, "C number  courses": 2}
312    /// ```
313    #[cfg(feature = "transform-spell-symbols")]
314    SpellSymbols {
315        /// Additional mappings (merged on top of [`default_symbol_map`];
316        /// entries with the same `from` override the default).
317        extra: HashMap<String, String>,
318        /// Inserted around each replacement so word boundaries survive a
319        /// downstream `keys_case` step. Default `" "`.
320        separator: String,
321    },
322
323    /// A user-supplied transformation function.
324    ///
325    /// The function receives each record as a [`Value`] and returns the
326    /// (possibly modified) record.  Construct one with [`RecordTransform::custom`].
327    ///
328    /// Always available — not guarded by any feature flag.
329    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
391// Arc<dyn Fn> is Clone (bumps refcount) but #[derive(Clone)] can't see that,
392// so we implement Clone manually.
393impl 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
451// Arc<dyn Fn> is Clone (bumps refcount) but #[derive(Clone)] can't see that,
452// so we implement Clone manually.
453impl 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    /// Create a custom transform from any function or closure.
513    ///
514    /// The closure receives each record as a [`Value`] and must return a
515    /// [`Value`] (the transformed record).  It is called once per record and
516    /// may perform any manipulation — adding fields, removing fields, renaming,
517    /// type coercion, etc.
518    ///
519    /// Custom transforms are always available regardless of feature flags.
520    ///
521    /// # Example
522    ///
523    /// ```rust
524    /// use faucet_core::RecordTransform;
525    /// use serde_json::{Value, json};
526    ///
527    /// // Inject a constant "source" field into every record.
528    /// let stamp = RecordTransform::custom(|mut record| {
529    ///     if let Value::Object(ref mut map) = record {
530    ///         map.insert("_source".to_string(), json!("my-api"));
531    ///     }
532    ///     record
533    /// });
534    /// ```
535    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
543// ── Internal compiled representation ─────────────────────────────────────────
544
545/// Pre-compiled form of a [`RecordTransform`].
546///
547/// Stored inside a source (e.g. the REST source's `RestStream`) so that regex
548/// patterns are compiled exactly once (at construction time) rather than once
549/// per record.
550pub 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        /// `(from, to)` pairs sorted by descending `from.len()` so longer
598        /// patterns win when prefixes overlap (e.g. `"<="` before `"<"`).
599        replacements: Vec<(String, String)>,
600        separator: String,
601    },
602    Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
603}
604
605/// Compile a [`RecordTransform`] into its [`CompiledTransform`] form.
606///
607/// Returns [`FaucetError::Transform`] if a regex pattern is invalid.
608pub 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            // Merge defaults + user overrides into a single ordered list,
662            // sorted longest-first so `"<="` beats `"<"` etc.
663            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
678/// Apply a slice of pre-compiled transforms to a record, in order.
679///
680/// Returns [`FaucetError::Transform`] if a transform would silently lose data
681/// — currently when `flatten`, `keys_case`, or `spell_symbols` collapse two
682/// distinct fields to the same key (#78/#28).
683pub 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// ── Flatten ───────────────────────────────────────────────────────────────────
725
726#[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            // Erroring (rather than last-wins) avoids silently dropping a value
758            // when a nested path and a literal key collide, e.g.
759            // `{"a__b":1,"a":{"b":2}}` both map to `a__b` (#78/#28).
760            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// ── Rename keys ───────────────────────────────────────────────────────────────
773
774#[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// ── KeysCase ──────────────────────────────────────────────────────────────────
797
798/// Recursively re-case every key in the record according to `mode`.
799#[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                    // An all-symbol key tokenises to nothing — keep the
808                    // original key instead of producing a blank one.
809                    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/// Split a key into word tokens.  Boundaries: whitespace, `_`, `-`, any
836/// other non-alphanumeric char, and lower→upper transitions (so
837/// `firstName` splits as `["first", "Name"]`).  Multi-char uppercase runs
838/// are left as one token (`"XMLParser"` → `["XMLParser"]`); document the
839/// limitation in the cookbook rather than complicating the tokeniser.
840#[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/// Lowercase the input then uppercase the first char.
904#[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// ── Select ────────────────────────────────────────────────────────────────────
915
916#[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            // Preserve `fields` order so downstream consumers get a stable layout.
922            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// ── Drop ──────────────────────────────────────────────────────────────────────
934
935#[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// ── Set ───────────────────────────────────────────────────────────────────────
949
950#[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// ── RenameField ───────────────────────────────────────────────────────────────
964
965#[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                    // Erroring (rather than overwriting) avoids silently dropping a
975                    // value when the target name already exists on the record —
976                    // mirrors the collision semantics in `flatten` / `keys_case`.
977                    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// ── Cast ──────────────────────────────────────────────────────────────────────
993
994#[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 => { /* leave as-is */ }
1020                    },
1021                }
1022            }
1023            Ok(Value::Object(map))
1024        }
1025        other => Ok(other),
1026    }
1027}
1028
1029/// Try to coerce a single [`Value`] to `target`.  Returns a human-readable
1030/// reason string on failure (the caller wraps it in `FaucetError::Transform`).
1031#[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                // A float-backed number only converts when it is a whole
1040                // number within i64 range. A fractional or out-of-range float
1041                // is an error rather than a silent truncate/saturate — so
1042                // `on_error` (error/null/skip) governs it as documented.
1043                // `2^63` is the exact f64 just above i64::MAX; `[-2^63, 2^63)`
1044                // with a zero fractional part round-trips losslessly.
1045                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// ── Redact ────────────────────────────────────────────────────────────────────
1144
1145#[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// ── ValueCase ─────────────────────────────────────────────────────────────────
1161
1162#[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// ── SpellSymbols ──────────────────────────────────────────────────────────────
1183
1184/// Built-in symbol → word map used by [`RecordTransform::SpellSymbols`].
1185///
1186/// The defaults cover the common ASCII symbols that downstream identifier
1187/// rules (`snake_case`, SQL column naming, JSON pointer paths) typically
1188/// strip or reject.  Symbols that are already identifier-safe (`_`, `-`,
1189/// `.`) are intentionally left alone; symbols that `keys_case` strips
1190/// outright (`(`, `)`, `[`, `]`, `:`, `,` …) are also omitted — chain
1191/// `keys_case` after `spell_symbols` if you need them removed.
1192#[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                // Erroring (rather than last-wins) avoids silently dropping a
1230                // value when two distinct keys spell to the same name — same
1231                // contract as `flatten` / `keys_case` (#78/#28).
1232                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/// Apply the (longest-first) `replacements` to a single key string,
1254/// inserting `separator` around each substitution so word boundaries
1255/// survive a downstream `keys_case` step.
1256#[cfg(feature = "transform-spell-symbols")]
1257fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
1258    // Walk the input left-to-right; at each position try the longest
1259    // replacement first. This avoids `"<="` being split by the shorter
1260    // `"<"` substitution.
1261    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            // Step by one UTF-8 char. We have to walk the &str slice (not
1279            // the byte buffer) to respect codepoint boundaries.
1280            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// ── Tests ─────────────────────────────────────────────────────────────────────
1292
1293#[cfg(test)]
1294mod tests {
1295    use super::*;
1296    use serde_json::json;
1297
1298    /// Test-only wrapper that shadows [`super::apply_all`] and unwraps, so the
1299    /// many existing success-path tests need no changes now that `apply_all`
1300    /// returns `Result`. Collision tests call `super::apply_all` for the
1301    /// `Result` directly.
1302    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    // ── Custom (always available) ─────────────────────────────────────────────
1311
1312    #[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    // ── Flatten ───────────────────────────────────────────────────────────────
1352
1353    #[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    // ── RenameKeys ────────────────────────────────────────────────────────────
1410
1411    #[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    // ── Chaining ──────────────────────────────────────────────────────────────
1488
1489    #[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        // Custom runs before (or after) built-ins — ordering is preserved.
1511        let record = json!({"id": 1, "raw_value": 100});
1512        let result = apply_all(
1513            record,
1514            &compiled(&[
1515                // Step 1: custom — double raw_value
1516                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                // Step 2: custom — rename raw_value → value
1525                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    // ── #78/#28: collisions must error, not silently drop ──────────────────
1541
1542    #[cfg(feature = "transform-flatten")]
1543    #[test]
1544    fn flatten_key_collision_errors() {
1545        // `a__b` (literal) and `a.b` (nested) both flatten to `a__b`.
1546        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    // ── Select ────────────────────────────────────────────────────────────────
1559
1560    #[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        // Listed field is absent — must not introduce a null.
1579        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    // ── Drop ──────────────────────────────────────────────────────────────────
1604
1605    #[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    // ── Set ───────────────────────────────────────────────────────────────────
1634
1635    #[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    // ── RenameField ───────────────────────────────────────────────────────────
1678
1679    #[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    // ── Cast ──────────────────────────────────────────────────────────────────
1724
1725    #[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        // A float with no fractional part and within i64 range converts.
1747        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        // A fractional float must surface an error, not silently truncate to 3.
1759        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        // A float beyond i64 range must error, not silently saturate to i64::MAX.
1772        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        // `+00:00` normalises to `Z` via chrono's RFC 3339 emitter.
1844        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    // ── Redact ────────────────────────────────────────────────────────────────
1895
1896    #[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    // ── ValueCase ─────────────────────────────────────────────────────────────
1928
1929    #[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    // ── SpellSymbols ──────────────────────────────────────────────────────────
1987
1988    #[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        // Defaults insert " " around each replacement so a downstream
2002        // snake_case picks up the word boundary.
2003        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        // `#` override beats the default `"number"`.
2044        assert!(result.get(" hash tag").is_some());
2045        // `©` is not in the default map but the user added it.
2046        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        // Without longest-first ordering, `"<"` would shadow `"<="`.
2053        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        // Confirm `<` alone was NOT applied separately.
2065        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        // With separator "" both keys collapse to "percent".
2087        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    // ── KeysCase ──────────────────────────────────────────────────────────────
2101
2102    #[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        // snake → camel
2121        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        // "firstName" and "first_name" both snake_case to "first_name".
2172        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        // A key that tokenises to nothing must keep its original form rather
2183        // than producing an empty-string key.
2184        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        // Re-running the transform should be a no-op once keys are already in
2194        // the target shape.
2195        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        // A non-ASCII char with a UTF-8 length > 1 must not corrupt the walk.
2208        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}