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        /// `(from, to)` pairs sorted by `from`, so application is deterministic
579        /// regardless of the source `HashMap`'s iteration order.
580        fields: Vec<(String, String)>,
581    },
582    #[cfg(feature = "transform-cast")]
583    Cast {
584        fields: HashMap<String, CastType>,
585        on_error: CastOnError,
586    },
587    #[cfg(feature = "transform-redact")]
588    Redact {
589        fields: Vec<String>,
590        mask: Value,
591    },
592    #[cfg(feature = "transform-value-case")]
593    ValueCase {
594        fields: Vec<String>,
595        mode: ValueCaseMode,
596    },
597    #[cfg(feature = "transform-spell-symbols")]
598    SpellSymbols {
599        /// `(from, to)` pairs sorted by descending `from.len()` so longer
600        /// patterns win when prefixes overlap (e.g. `"<="` before `"<"`).
601        replacements: Vec<(String, String)>,
602        separator: String,
603    },
604    Custom(Arc<dyn Fn(Value) -> Value + Send + Sync>),
605}
606
607/// Compile a [`RecordTransform`] into its [`CompiledTransform`] form.
608///
609/// Returns [`FaucetError::Transform`] if a regex pattern is invalid.
610pub fn compile(t: &RecordTransform) -> Result<CompiledTransform, FaucetError> {
611    match t {
612        #[cfg(feature = "transform-flatten")]
613        RecordTransform::Flatten { separator } => Ok(CompiledTransform::Flatten {
614            separator: separator.clone(),
615        }),
616        #[cfg(feature = "transform-rename-keys")]
617        RecordTransform::RenameKeys {
618            pattern,
619            replacement,
620        } => {
621            let re = Regex::new(pattern)
622                .map_err(|e| FaucetError::Transform(format!("invalid regex '{pattern}': {e}")))?;
623            Ok(CompiledTransform::RenameKeys {
624                re,
625                replacement: replacement.clone(),
626            })
627        }
628        #[cfg(feature = "transform-keys-case")]
629        RecordTransform::KeysCase { mode } => Ok(CompiledTransform::KeysCase { mode: *mode }),
630        #[cfg(feature = "transform-select")]
631        RecordTransform::Select { fields } => Ok(CompiledTransform::Select {
632            fields: fields.clone(),
633        }),
634        #[cfg(feature = "transform-drop")]
635        RecordTransform::Drop { fields } => Ok(CompiledTransform::Drop {
636            fields: fields.clone(),
637        }),
638        #[cfg(feature = "transform-set")]
639        RecordTransform::Set { values } => Ok(CompiledTransform::Set {
640            values: values.clone(),
641        }),
642        #[cfg(feature = "transform-rename-field")]
643        RecordTransform::RenameField { fields } => {
644            // Materialize into a stable, sorted order so renames apply
645            // deterministically — a `HashMap`'s iteration order is randomized,
646            // which made interacting renames (chains/swaps) produce unstable or
647            // corrupted output and intermittent collision errors.
648            let mut fields: Vec<(String, String)> =
649                fields.iter().map(|(f, t)| (f.clone(), t.clone())).collect();
650            fields.sort();
651            Ok(CompiledTransform::RenameField { fields })
652        }
653        #[cfg(feature = "transform-cast")]
654        RecordTransform::Cast { fields, on_error } => Ok(CompiledTransform::Cast {
655            fields: fields.clone(),
656            on_error: *on_error,
657        }),
658        #[cfg(feature = "transform-redact")]
659        RecordTransform::Redact { fields, mask } => Ok(CompiledTransform::Redact {
660            fields: fields.clone(),
661            mask: mask.clone(),
662        }),
663        #[cfg(feature = "transform-value-case")]
664        RecordTransform::ValueCase { fields, mode } => Ok(CompiledTransform::ValueCase {
665            fields: fields.clone(),
666            mode: *mode,
667        }),
668        #[cfg(feature = "transform-spell-symbols")]
669        RecordTransform::SpellSymbols { extra, separator } => {
670            // Merge defaults + user overrides into a single ordered list,
671            // sorted longest-first so `"<="` beats `"<"` etc.
672            let mut merged = default_symbol_map();
673            for (k, v) in extra {
674                merged.insert(k.clone(), v.clone());
675            }
676            let mut replacements: Vec<(String, String)> = merged.into_iter().collect();
677            replacements.sort_by_key(|b| std::cmp::Reverse(b.0.len()));
678            Ok(CompiledTransform::SpellSymbols {
679                replacements,
680                separator: separator.clone(),
681            })
682        }
683        RecordTransform::Custom(f) => Ok(CompiledTransform::Custom(Arc::clone(f))),
684    }
685}
686
687/// Apply a slice of pre-compiled transforms to a record, in order.
688///
689/// Returns [`FaucetError::Transform`] if a transform would silently lose data
690/// — currently when `flatten`, `keys_case`, or `spell_symbols` collapse two
691/// distinct fields to the same key (#78/#28).
692pub fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Result<Value, FaucetError> {
693    let mut acc = record;
694    for t in transforms {
695        acc = apply_one(acc, t)?;
696    }
697    Ok(acc)
698}
699
700fn apply_one(value: Value, t: &CompiledTransform) -> Result<Value, FaucetError> {
701    match t {
702        #[cfg(feature = "transform-flatten")]
703        CompiledTransform::Flatten { separator } => flatten(value, separator),
704        #[cfg(feature = "transform-rename-keys")]
705        CompiledTransform::RenameKeys { re, replacement } => {
706            Ok(rename_keys(value, re, replacement))
707        }
708        #[cfg(feature = "transform-keys-case")]
709        CompiledTransform::KeysCase { mode } => keys_case(value, *mode),
710        #[cfg(feature = "transform-select")]
711        CompiledTransform::Select { fields } => Ok(select_fields(value, fields)),
712        #[cfg(feature = "transform-drop")]
713        CompiledTransform::Drop { fields } => Ok(drop_fields(value, fields)),
714        #[cfg(feature = "transform-set")]
715        CompiledTransform::Set { values } => Ok(set_fields(value, values)),
716        #[cfg(feature = "transform-rename-field")]
717        CompiledTransform::RenameField { fields } => rename_field(value, fields),
718        #[cfg(feature = "transform-cast")]
719        CompiledTransform::Cast { fields, on_error } => cast_fields(value, fields, *on_error),
720        #[cfg(feature = "transform-redact")]
721        CompiledTransform::Redact { fields, mask } => Ok(redact_fields(value, fields, mask)),
722        #[cfg(feature = "transform-value-case")]
723        CompiledTransform::ValueCase { fields, mode } => Ok(value_case(value, fields, *mode)),
724        #[cfg(feature = "transform-spell-symbols")]
725        CompiledTransform::SpellSymbols {
726            replacements,
727            separator,
728        } => spell_symbols(value, replacements, separator),
729        CompiledTransform::Custom(f) => Ok(f(value)),
730    }
731}
732
733// ── Flatten ───────────────────────────────────────────────────────────────────
734
735#[cfg(feature = "transform-flatten")]
736fn flatten(value: Value, separator: &str) -> Result<Value, FaucetError> {
737    match value {
738        Value::Object(_) => {
739            let mut out = Map::new();
740            flatten_into(value, "", separator, &mut out)?;
741            Ok(Value::Object(out))
742        }
743        other => Ok(other),
744    }
745}
746
747#[cfg(feature = "transform-flatten")]
748fn flatten_into(
749    value: Value,
750    prefix: &str,
751    separator: &str,
752    out: &mut Map<String, Value>,
753) -> Result<(), FaucetError> {
754    match value {
755        Value::Object(map) => {
756            for (k, v) in map {
757                let key = if prefix.is_empty() {
758                    k
759                } else {
760                    format!("{prefix}{separator}{k}")
761                };
762                flatten_into(v, &key, separator, out)?;
763            }
764        }
765        other => {
766            // Erroring (rather than last-wins) avoids silently dropping a value
767            // when a nested path and a literal key collide, e.g.
768            // `{"a__b":1,"a":{"b":2}}` both map to `a__b` (#78/#28).
769            if out.contains_key(prefix) {
770                return Err(FaucetError::Transform(format!(
771                    "flatten produced a duplicate key '{prefix}'; two distinct fields collapse \
772                     to the same flattened key (separator '{separator}')"
773                )));
774            }
775            out.insert(prefix.to_string(), other);
776        }
777    }
778    Ok(())
779}
780
781// ── Rename keys ───────────────────────────────────────────────────────────────
782
783#[cfg(feature = "transform-rename-keys")]
784fn rename_keys(value: Value, re: &Regex, replacement: &str) -> Value {
785    match value {
786        Value::Object(map) => {
787            let new_map: Map<String, Value> = map
788                .into_iter()
789                .map(|(k, v)| {
790                    let new_k = re.replace_all(&k, replacement).into_owned();
791                    (new_k, rename_keys(v, re, replacement))
792                })
793                .collect();
794            Value::Object(new_map)
795        }
796        Value::Array(arr) => Value::Array(
797            arr.into_iter()
798                .map(|v| rename_keys(v, re, replacement))
799                .collect(),
800        ),
801        other => other,
802    }
803}
804
805// ── KeysCase ──────────────────────────────────────────────────────────────────
806
807/// Recursively re-case every key in the record according to `mode`.
808#[cfg(feature = "transform-keys-case")]
809fn keys_case(value: Value, mode: KeyCaseMode) -> Result<Value, FaucetError> {
810    match value {
811        Value::Object(map) => {
812            let mut new_map = Map::with_capacity(map.len());
813            for (k, v) in map {
814                let tokens = tokenize_key(&k);
815                let recased = if tokens.is_empty() {
816                    // An all-symbol key tokenises to nothing — keep the
817                    // original key instead of producing a blank one.
818                    k
819                } else {
820                    apply_key_case(tokens, mode)
821                };
822                let new_v = keys_case(v, mode)?;
823                if new_map.contains_key(&recased) {
824                    return Err(FaucetError::Transform(format!(
825                        "keys_case produced a duplicate key '{recased}'; two distinct keys \
826                         re-case to the same name under mode {mode:?}"
827                    )));
828                }
829                new_map.insert(recased, new_v);
830            }
831            Ok(Value::Object(new_map))
832        }
833        Value::Array(arr) => {
834            let mut out = Vec::with_capacity(arr.len());
835            for v in arr {
836                out.push(keys_case(v, mode)?);
837            }
838            Ok(Value::Array(out))
839        }
840        other => Ok(other),
841    }
842}
843
844/// Split a key into word tokens.  Boundaries: whitespace, `_`, `-`, any
845/// other non-alphanumeric char, and lower→upper transitions (so
846/// `firstName` splits as `["first", "Name"]`).  Multi-char uppercase runs
847/// are left as one token (`"XMLParser"` → `["XMLParser"]`); document the
848/// limitation in the cookbook rather than complicating the tokeniser.
849#[cfg(feature = "transform-keys-case")]
850fn tokenize_key(key: &str) -> Vec<String> {
851    let mut tokens: Vec<String> = Vec::new();
852    let mut current = String::new();
853    let mut prev_was_lower = false;
854    for ch in key.chars() {
855        if ch.is_alphanumeric() {
856            if prev_was_lower && ch.is_uppercase() && !current.is_empty() {
857                tokens.push(std::mem::take(&mut current));
858            }
859            current.push(ch);
860            prev_was_lower = ch.is_lowercase();
861        } else {
862            if !current.is_empty() {
863                tokens.push(std::mem::take(&mut current));
864            }
865            prev_was_lower = false;
866        }
867    }
868    if !current.is_empty() {
869        tokens.push(current);
870    }
871    tokens
872}
873
874#[cfg(feature = "transform-keys-case")]
875fn apply_key_case(tokens: Vec<String>, mode: KeyCaseMode) -> String {
876    match mode {
877        KeyCaseMode::Snake => tokens
878            .iter()
879            .map(|t| t.to_lowercase())
880            .collect::<Vec<_>>()
881            .join("_"),
882        KeyCaseMode::ScreamingSnake => tokens
883            .iter()
884            .map(|t| t.to_uppercase())
885            .collect::<Vec<_>>()
886            .join("_"),
887        KeyCaseMode::Kebab => tokens
888            .iter()
889            .map(|t| t.to_lowercase())
890            .collect::<Vec<_>>()
891            .join("-"),
892        KeyCaseMode::Camel => {
893            let mut iter = tokens.into_iter();
894            match iter.next() {
895                None => String::new(),
896                Some(first) => {
897                    let mut out = first.to_lowercase();
898                    for t in iter {
899                        out.push_str(&capitalize_token(&t));
900                    }
901                    out
902                }
903            }
904        }
905        KeyCaseMode::Pascal => tokens
906            .into_iter()
907            .map(|t| capitalize_token(&t))
908            .collect::<String>(),
909    }
910}
911
912/// Lowercase the input then uppercase the first char.
913#[cfg(feature = "transform-keys-case")]
914fn capitalize_token(s: &str) -> String {
915    let lower = s.to_lowercase();
916    let mut chars = lower.chars();
917    match chars.next() {
918        None => String::new(),
919        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
920    }
921}
922
923// ── Select ────────────────────────────────────────────────────────────────────
924
925#[cfg(feature = "transform-select")]
926fn select_fields(value: Value, fields: &[String]) -> Value {
927    match value {
928        Value::Object(map) => {
929            let mut out = Map::with_capacity(fields.len().min(map.len()));
930            // Preserve `fields` order so downstream consumers get a stable layout.
931            for f in fields {
932                if let Some(v) = map.get(f) {
933                    out.insert(f.clone(), v.clone());
934                }
935            }
936            Value::Object(out)
937        }
938        other => other,
939    }
940}
941
942// ── Drop ──────────────────────────────────────────────────────────────────────
943
944#[cfg(feature = "transform-drop")]
945fn drop_fields(value: Value, fields: &[String]) -> Value {
946    match value {
947        Value::Object(mut map) => {
948            for f in fields {
949                map.remove(f);
950            }
951            Value::Object(map)
952        }
953        other => other,
954    }
955}
956
957// ── Set ───────────────────────────────────────────────────────────────────────
958
959#[cfg(feature = "transform-set")]
960fn set_fields(value: Value, values: &Map<String, Value>) -> Value {
961    match value {
962        Value::Object(mut map) => {
963            for (k, v) in values {
964                map.insert(k.clone(), v.clone());
965            }
966            Value::Object(map)
967        }
968        other => other,
969    }
970}
971
972// ── RenameField ───────────────────────────────────────────────────────────────
973
974#[cfg(feature = "transform-rename-field")]
975fn rename_field(value: Value, fields: &[(String, String)]) -> Result<Value, FaucetError> {
976    match value {
977        Value::Object(mut map) => {
978            // Apply every rename against the ORIGINAL record (a snapshot), not
979            // sequentially against a mutating map. This makes interacting renames
980            // — chains (`{a:b, b:c}`) and swaps (`{a:b, b:a}`) — deterministic and
981            // order-independent: each source's value moves to its target as it was
982            // before any rename, and sources are removed atomically.
983            let renames: Vec<(&str, &str)> = fields
984                .iter()
985                .filter(|(from, to)| from != to && map.contains_key(from))
986                .map(|(from, to)| (from.as_str(), to.as_str()))
987                .collect();
988            let sources: std::collections::HashSet<&str> =
989                renames.iter().map(|(from, _)| *from).collect();
990
991            // Validate before mutating.
992            let mut seen_targets: std::collections::HashSet<&str> =
993                std::collections::HashSet::new();
994            for (from, to) in &renames {
995                if !seen_targets.insert(to) {
996                    return Err(FaucetError::Transform(format!(
997                        "rename_field: two fields rename to the same target key '{to}'"
998                    )));
999                }
1000                // A target collides only if a *surviving* key (one not being
1001                // renamed away) already occupies it — mirrors the collision
1002                // semantics in `flatten` / `keys_case`.
1003                if map.contains_key(*to) && !sources.contains(to) {
1004                    return Err(FaucetError::Transform(format!(
1005                        "rename_field: target key '{to}' already exists on the record \
1006                         (renaming from '{from}')"
1007                    )));
1008                }
1009            }
1010
1011            let staged: Vec<(String, Value)> = renames
1012                .iter()
1013                .map(|(from, to)| {
1014                    let v = map.remove(*from).expect("source presence checked above");
1015                    (to.to_string(), v)
1016                })
1017                .collect();
1018            for (to, v) in staged {
1019                map.insert(to, v);
1020            }
1021            Ok(Value::Object(map))
1022        }
1023        other => Ok(other),
1024    }
1025}
1026
1027// ── Cast ──────────────────────────────────────────────────────────────────────
1028
1029#[cfg(feature = "transform-cast")]
1030fn cast_fields(
1031    value: Value,
1032    fields: &HashMap<String, CastType>,
1033    on_error: CastOnError,
1034) -> Result<Value, FaucetError> {
1035    match value {
1036        Value::Object(mut map) => {
1037            for (field, target) in fields {
1038                let Some(current) = map.get(field) else {
1039                    continue;
1040                };
1041                match cast_value(current, *target) {
1042                    Ok(new_val) => {
1043                        map.insert(field.clone(), new_val);
1044                    }
1045                    Err(msg) => match on_error {
1046                        CastOnError::Error => {
1047                            return Err(FaucetError::Transform(format!(
1048                                "cast: field '{field}' to {target:?} failed: {msg}"
1049                            )));
1050                        }
1051                        CastOnError::Null => {
1052                            map.insert(field.clone(), Value::Null);
1053                        }
1054                        CastOnError::Skip => { /* leave as-is */ }
1055                    },
1056                }
1057            }
1058            Ok(Value::Object(map))
1059        }
1060        other => Ok(other),
1061    }
1062}
1063
1064/// Try to coerce a single [`Value`] to `target`.  Returns a human-readable
1065/// reason string on failure (the caller wraps it in `FaucetError::Transform`).
1066#[cfg(feature = "transform-cast")]
1067fn cast_value(v: &Value, target: CastType) -> Result<Value, String> {
1068    match target {
1069        CastType::Int => match v {
1070            Value::Number(n) => {
1071                if let Some(i) = n.as_i64() {
1072                    return Ok(Value::Number(i.into()));
1073                }
1074                // A float-backed number only converts when it is a whole
1075                // number within i64 range. A fractional or out-of-range float
1076                // is an error rather than a silent truncate/saturate — so
1077                // `on_error` (error/null/skip) governs it as documented.
1078                // `2^63` is the exact f64 just above i64::MAX; `[-2^63, 2^63)`
1079                // with a zero fractional part round-trips losslessly.
1080                match n.as_f64() {
1081                    Some(f)
1082                        if f.fract() == 0.0 && (-(2f64.powi(63))..2f64.powi(63)).contains(&f) =>
1083                    {
1084                        Ok(Value::Number((f as i64).into()))
1085                    }
1086                    Some(f) => Err(format!(
1087                        "float '{f}' is not a whole number representable as i64"
1088                    )),
1089                    None => Err(format!("number '{n}' is not representable as i64")),
1090                }
1091            }
1092            Value::String(s) => s
1093                .trim()
1094                .parse::<i64>()
1095                .map(|i| Value::Number(i.into()))
1096                .map_err(|e| format!("'{s}' is not an integer: {e}")),
1097            Value::Bool(b) => Ok(Value::Number(i64::from(*b).into())),
1098            Value::Null => Err("null cannot be cast to int".to_owned()),
1099            Value::Array(_) | Value::Object(_) => {
1100                Err("composite values cannot be cast to int".to_owned())
1101            }
1102        },
1103        CastType::Float => match v {
1104            Value::Number(n) => n
1105                .as_f64()
1106                .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1107                .ok_or_else(|| format!("number '{n}' is not representable as f64")),
1108            Value::String(s) => s
1109                .trim()
1110                .parse::<f64>()
1111                .ok()
1112                .and_then(|f| serde_json::Number::from_f64(f).map(Value::Number))
1113                .ok_or_else(|| format!("'{s}' is not a float")),
1114            Value::Bool(b) => serde_json::Number::from_f64(if *b { 1.0 } else { 0.0 })
1115                .map(Value::Number)
1116                .ok_or_else(|| "could not encode bool as f64".to_owned()),
1117            Value::Null => Err("null cannot be cast to float".to_owned()),
1118            Value::Array(_) | Value::Object(_) => {
1119                Err("composite values cannot be cast to float".to_owned())
1120            }
1121        },
1122        CastType::Bool => match v {
1123            Value::Bool(b) => Ok(Value::Bool(*b)),
1124            Value::Number(n) => {
1125                if let Some(i) = n.as_i64() {
1126                    match i {
1127                        0 => Ok(Value::Bool(false)),
1128                        1 => Ok(Value::Bool(true)),
1129                        _ => Err(format!("integer {i} is not 0 or 1")),
1130                    }
1131                } else {
1132                    Err(format!("number '{n}' is not 0 or 1"))
1133                }
1134            }
1135            Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
1136                "true" | "1" | "yes" | "y" => Ok(Value::Bool(true)),
1137                "false" | "0" | "no" | "n" => Ok(Value::Bool(false)),
1138                other => Err(format!("'{other}' is not a recognised boolean")),
1139            },
1140            Value::Null => Err("null cannot be cast to bool".to_owned()),
1141            Value::Array(_) | Value::Object(_) => {
1142                Err("composite values cannot be cast to bool".to_owned())
1143            }
1144        },
1145        CastType::String => match v {
1146            Value::String(s) => Ok(Value::String(s.clone())),
1147            Value::Number(n) => Ok(Value::String(n.to_string())),
1148            Value::Bool(b) => Ok(Value::String(b.to_string())),
1149            Value::Null => Err("null cannot be cast to string".to_owned()),
1150            Value::Array(_) | Value::Object(_) => {
1151                Err("composite values cannot be cast to string".to_owned())
1152            }
1153        },
1154        CastType::Timestamp => match v {
1155            Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
1156                .map(|dt| Value::String(dt.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)))
1157                .map_err(|e| format!("'{s}' is not a valid RFC 3339 timestamp: {e}")),
1158            other => Err(format!(
1159                "cannot cast {} to timestamp (expected RFC 3339 string)",
1160                value_type_name(other)
1161            )),
1162        },
1163    }
1164}
1165
1166#[cfg(feature = "transform-cast")]
1167fn value_type_name(v: &Value) -> &'static str {
1168    match v {
1169        Value::Null => "null",
1170        Value::Bool(_) => "bool",
1171        Value::Number(_) => "number",
1172        Value::String(_) => "string",
1173        Value::Array(_) => "array",
1174        Value::Object(_) => "object",
1175    }
1176}
1177
1178// ── Redact ────────────────────────────────────────────────────────────────────
1179
1180#[cfg(feature = "transform-redact")]
1181fn redact_fields(value: Value, fields: &[String], mask: &Value) -> Value {
1182    match value {
1183        Value::Object(mut map) => {
1184            for f in fields {
1185                if map.contains_key(f) {
1186                    map.insert(f.clone(), mask.clone());
1187                }
1188            }
1189            Value::Object(map)
1190        }
1191        other => other,
1192    }
1193}
1194
1195// ── ValueCase ─────────────────────────────────────────────────────────────────
1196
1197#[cfg(feature = "transform-value-case")]
1198fn value_case(value: Value, fields: &[String], mode: ValueCaseMode) -> Value {
1199    match value {
1200        Value::Object(mut map) => {
1201            for f in fields {
1202                if let Some(Value::String(s)) = map.get(f) {
1203                    let new_s = match mode {
1204                        ValueCaseMode::Lower => s.to_lowercase(),
1205                        ValueCaseMode::Upper => s.to_uppercase(),
1206                        ValueCaseMode::Trim => s.trim().to_owned(),
1207                    };
1208                    map.insert(f.clone(), Value::String(new_s));
1209                }
1210            }
1211            Value::Object(map)
1212        }
1213        other => other,
1214    }
1215}
1216
1217// ── SpellSymbols ──────────────────────────────────────────────────────────────
1218
1219/// Built-in symbol → word map used by [`RecordTransform::SpellSymbols`].
1220///
1221/// The defaults cover the common ASCII symbols that downstream identifier
1222/// rules (`snake_case`, SQL column naming, JSON pointer paths) typically
1223/// strip or reject.  Symbols that are already identifier-safe (`_`, `-`,
1224/// `.`) are intentionally left alone; symbols that `keys_case` strips
1225/// outright (`(`, `)`, `[`, `]`, `:`, `,` …) are also omitted — chain
1226/// `keys_case` after `spell_symbols` if you need them removed.
1227#[cfg(feature = "transform-spell-symbols")]
1228pub fn default_symbol_map() -> HashMap<String, String> {
1229    let pairs: &[(&str, &str)] = &[
1230        ("%", "percent"),
1231        ("#", "number"),
1232        ("$", "dollar"),
1233        ("&", "and"),
1234        ("@", "at"),
1235        ("+", "plus"),
1236        ("*", "star"),
1237        ("=", "equals"),
1238        ("<", "lt"),
1239        (">", "gt"),
1240        ("/", "slash"),
1241        ("\\", "backslash"),
1242        ("|", "pipe"),
1243        ("^", "caret"),
1244        ("~", "tilde"),
1245    ];
1246    pairs
1247        .iter()
1248        .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1249        .collect()
1250}
1251
1252#[cfg(feature = "transform-spell-symbols")]
1253fn spell_symbols(
1254    value: Value,
1255    replacements: &[(String, String)],
1256    separator: &str,
1257) -> Result<Value, FaucetError> {
1258    match value {
1259        Value::Object(map) => {
1260            let mut new_map = Map::with_capacity(map.len());
1261            for (k, v) in map {
1262                let new_k = spell_symbols_in_key(&k, replacements, separator);
1263                let new_v = spell_symbols(v, replacements, separator)?;
1264                // Erroring (rather than last-wins) avoids silently dropping a
1265                // value when two distinct keys spell to the same name — same
1266                // contract as `flatten` / `keys_case` (#78/#28).
1267                if new_map.contains_key(&new_k) {
1268                    return Err(FaucetError::Transform(format!(
1269                        "spell_symbols produced a duplicate key '{new_k}'; two distinct keys \
1270                         expand to the same name"
1271                    )));
1272                }
1273                new_map.insert(new_k, new_v);
1274            }
1275            Ok(Value::Object(new_map))
1276        }
1277        Value::Array(arr) => {
1278            let mut out = Vec::with_capacity(arr.len());
1279            for v in arr {
1280                out.push(spell_symbols(v, replacements, separator)?);
1281            }
1282            Ok(Value::Array(out))
1283        }
1284        other => Ok(other),
1285    }
1286}
1287
1288/// Apply the (longest-first) `replacements` to a single key string,
1289/// inserting `separator` around each substitution so word boundaries
1290/// survive a downstream `keys_case` step.
1291#[cfg(feature = "transform-spell-symbols")]
1292fn spell_symbols_in_key(key: &str, replacements: &[(String, String)], separator: &str) -> String {
1293    // Walk the input left-to-right; at each position try the longest
1294    // replacement first. This avoids `"<="` being split by the shorter
1295    // `"<"` substitution.
1296    let bytes = key.as_bytes();
1297    let mut out = String::with_capacity(key.len());
1298    let mut i = 0;
1299    while i < bytes.len() {
1300        let mut matched = false;
1301        for (from, to) in replacements {
1302            let f = from.as_bytes();
1303            if !f.is_empty() && bytes[i..].starts_with(f) {
1304                out.push_str(separator);
1305                out.push_str(to);
1306                out.push_str(separator);
1307                i += f.len();
1308                matched = true;
1309                break;
1310            }
1311        }
1312        if !matched {
1313            // Step by one UTF-8 char. We have to walk the &str slice (not
1314            // the byte buffer) to respect codepoint boundaries.
1315            let ch = key[i..]
1316                .chars()
1317                .next()
1318                .expect("non-empty slice yields at least one char");
1319            out.push(ch);
1320            i += ch.len_utf8();
1321        }
1322    }
1323    out
1324}
1325
1326// ── Tests ─────────────────────────────────────────────────────────────────────
1327
1328#[cfg(test)]
1329mod tests {
1330    use super::*;
1331    use serde_json::json;
1332
1333    /// Test-only wrapper that shadows [`super::apply_all`] and unwraps, so the
1334    /// many existing success-path tests need no changes now that `apply_all`
1335    /// returns `Result`. Collision tests call `super::apply_all` for the
1336    /// `Result` directly.
1337    fn apply_all(record: Value, transforms: &[CompiledTransform]) -> Value {
1338        super::apply_all(record, transforms).expect("transform should succeed in this test")
1339    }
1340
1341    fn compiled(transforms: &[RecordTransform]) -> Vec<CompiledTransform> {
1342        transforms.iter().map(|t| compile(t).unwrap()).collect()
1343    }
1344
1345    // ── Custom (always available) ─────────────────────────────────────────────
1346
1347    #[test]
1348    fn test_custom_adds_field() {
1349        let record = json!({"id": 1});
1350        let result = apply_all(
1351            record,
1352            &compiled(&[RecordTransform::custom(|mut v| {
1353                if let Value::Object(ref mut m) = v {
1354                    m.insert("added".to_string(), json!(true));
1355                }
1356                v
1357            })]),
1358        );
1359        assert_eq!(result["id"], 1);
1360        assert_eq!(result["added"], true);
1361    }
1362
1363    #[test]
1364    fn test_custom_removes_field() {
1365        let record = json!({"id": 1, "secret": "drop_me"});
1366        let result = apply_all(
1367            record,
1368            &compiled(&[RecordTransform::custom(|mut v| {
1369                if let Value::Object(ref mut m) = v {
1370                    m.remove("secret");
1371                }
1372                v
1373            })]),
1374        );
1375        assert_eq!(result["id"], 1);
1376        assert!(result.get("secret").is_none());
1377    }
1378
1379    #[test]
1380    fn test_no_transforms_is_identity() {
1381        let record = json!({"id": 1, "name": "Alice"});
1382        let result = apply_all(record.clone(), &[]);
1383        assert_eq!(result, record);
1384    }
1385
1386    // ── Flatten ───────────────────────────────────────────────────────────────
1387
1388    #[cfg(feature = "transform-flatten")]
1389    #[test]
1390    fn test_flatten_nested_object() {
1391        let record = json!({"a": {"b": 1, "c": {"d": 2}}, "e": 3});
1392        let result = apply_all(
1393            record,
1394            &compiled(&[RecordTransform::Flatten {
1395                separator: "__".into(),
1396            }]),
1397        );
1398        assert_eq!(result["a__b"], 1);
1399        assert_eq!(result["a__c__d"], 2);
1400        assert_eq!(result["e"], 3);
1401        assert!(result.get("a").is_none(), "nested key should be removed");
1402    }
1403
1404    #[cfg(feature = "transform-flatten")]
1405    #[test]
1406    fn test_flatten_leaves_arrays_intact() {
1407        let record = json!({"tags": ["rust", "api"], "meta": {"count": 2}});
1408        let result = apply_all(
1409            record,
1410            &compiled(&[RecordTransform::Flatten {
1411                separator: ".".into(),
1412            }]),
1413        );
1414        assert_eq!(result["tags"], json!(["rust", "api"]));
1415        assert_eq!(result["meta.count"], 2);
1416    }
1417
1418    #[cfg(feature = "transform-flatten")]
1419    #[test]
1420    fn test_flatten_already_flat() {
1421        let record = json!({"id": 1, "name": "Alice"});
1422        let result = apply_all(
1423            record.clone(),
1424            &compiled(&[RecordTransform::Flatten {
1425                separator: "__".into(),
1426            }]),
1427        );
1428        assert_eq!(result, record);
1429    }
1430
1431    #[cfg(feature = "transform-flatten")]
1432    #[test]
1433    fn test_flatten_empty_separator() {
1434        let record = json!({"a": {"b": 1}});
1435        let result = apply_all(
1436            record,
1437            &compiled(&[RecordTransform::Flatten {
1438                separator: "".into(),
1439            }]),
1440        );
1441        assert_eq!(result["ab"], 1);
1442    }
1443
1444    // ── RenameKeys ────────────────────────────────────────────────────────────
1445
1446    #[cfg(feature = "transform-rename-keys")]
1447    #[test]
1448    fn test_rename_keys_strips_prefix() {
1449        let record = json!({"_prefix_id": 1, "_prefix_name": "Alice"});
1450        let result = apply_all(
1451            record,
1452            &compiled(&[RecordTransform::RenameKeys {
1453                pattern: r"^_prefix_".into(),
1454                replacement: "".into(),
1455            }]),
1456        );
1457        assert_eq!(result["id"], 1);
1458        assert_eq!(result["name"], "Alice");
1459    }
1460
1461    #[cfg(feature = "transform-rename-keys")]
1462    #[test]
1463    fn test_rename_keys_uppercase_to_placeholder() {
1464        let record = json!({"OUTER": {"INNER": 42}});
1465        let result = apply_all(
1466            record,
1467            &compiled(&[RecordTransform::RenameKeys {
1468                pattern: r"[A-Z]+".into(),
1469                replacement: "x".into(),
1470            }]),
1471        );
1472        assert_eq!(result["x"]["x"], 42);
1473    }
1474
1475    #[cfg(feature = "transform-rename-keys")]
1476    #[test]
1477    fn test_rename_keys_in_array_elements() {
1478        let record = json!({"items": [{"KEY": 1}, {"KEY": 2}]});
1479        let result = apply_all(
1480            record,
1481            &compiled(&[RecordTransform::RenameKeys {
1482                pattern: r"KEY".into(),
1483                replacement: "key".into(),
1484            }]),
1485        );
1486        assert_eq!(result["items"][0]["key"], 1);
1487        assert_eq!(result["items"][1]["key"], 2);
1488    }
1489
1490    #[cfg(feature = "transform-rename-keys")]
1491    #[test]
1492    fn test_rename_keys_invalid_regex_errors_at_compile() {
1493        let err = compile(&RecordTransform::RenameKeys {
1494            pattern: "[invalid".into(),
1495            replacement: "".into(),
1496        });
1497        assert!(err.is_err());
1498        assert!(matches!(err, Err(FaucetError::Transform(_))));
1499    }
1500
1501    #[cfg(feature = "transform-rename-keys")]
1502    #[test]
1503    fn test_rename_keys_chained() {
1504        let record = json!({"__camelCase__": 1});
1505        let result = apply_all(
1506            record,
1507            &compiled(&[
1508                RecordTransform::RenameKeys {
1509                    pattern: r"^_+|_+$".into(),
1510                    replacement: "".into(),
1511                },
1512                RecordTransform::RenameKeys {
1513                    pattern: r"[A-Z]".into(),
1514                    replacement: "_".into(),
1515                },
1516            ]),
1517        );
1518        let key = result.as_object().unwrap().keys().next().unwrap().clone();
1519        assert_eq!(key, "camel_ase");
1520    }
1521
1522    // ── Chaining ──────────────────────────────────────────────────────────────
1523
1524    #[cfg(all(feature = "transform-keys-case", feature = "transform-flatten"))]
1525    #[test]
1526    fn test_keys_case_then_flatten() {
1527        let record = json!({"User Info": {"First Name": "Alice", "Last Name": "Smith"}});
1528        let result = apply_all(
1529            record,
1530            &compiled(&[
1531                RecordTransform::KeysCase {
1532                    mode: KeyCaseMode::Snake,
1533                },
1534                RecordTransform::Flatten {
1535                    separator: "_".into(),
1536                },
1537            ]),
1538        );
1539        assert_eq!(result["user_info_first_name"], "Alice");
1540        assert_eq!(result["user_info_last_name"], "Smith");
1541    }
1542
1543    #[test]
1544    fn test_custom_chained_with_builtin() {
1545        // Custom runs before (or after) built-ins — ordering is preserved.
1546        let record = json!({"id": 1, "raw_value": 100});
1547        let result = apply_all(
1548            record,
1549            &compiled(&[
1550                // Step 1: custom — double raw_value
1551                RecordTransform::custom(|mut v| {
1552                    if let Some(n) = v.get("raw_value").and_then(|n| n.as_i64())
1553                        && let Value::Object(ref mut m) = v
1554                    {
1555                        m.insert("raw_value".to_string(), json!(n * 2));
1556                    }
1557                    v
1558                }),
1559                // Step 2: custom — rename raw_value → value
1560                RecordTransform::custom(|mut v| {
1561                    if let Value::Object(ref mut m) = v
1562                        && let Some(val) = m.remove("raw_value")
1563                    {
1564                        m.insert("value".to_string(), val);
1565                    }
1566                    v
1567                }),
1568            ]),
1569        );
1570        assert_eq!(result["id"], 1);
1571        assert_eq!(result["value"], 200);
1572        assert!(result.get("raw_value").is_none());
1573    }
1574
1575    // ── #78/#28: collisions must error, not silently drop ──────────────────
1576
1577    #[cfg(feature = "transform-flatten")]
1578    #[test]
1579    fn flatten_key_collision_errors() {
1580        // `a__b` (literal) and `a.b` (nested) both flatten to `a__b`.
1581        let record = json!({"a__b": 1, "a": {"b": 2}});
1582        let err = super::apply_all(
1583            record,
1584            &compiled(&[RecordTransform::Flatten {
1585                separator: "__".into(),
1586            }]),
1587        )
1588        .expect_err("colliding flattened keys must error, not drop a value");
1589        assert!(matches!(err, FaucetError::Transform(_)));
1590        assert!(format!("{err}").contains("a__b"), "{err}");
1591    }
1592
1593    // ── Select ────────────────────────────────────────────────────────────────
1594
1595    #[cfg(feature = "transform-select")]
1596    #[test]
1597    fn select_keeps_only_listed_fields() {
1598        let record = json!({"id": 1, "name": "Alice", "secret": "drop"});
1599        let result = apply_all(
1600            record,
1601            &compiled(&[RecordTransform::Select {
1602                fields: vec!["id".into(), "name".into()],
1603            }]),
1604        );
1605        assert_eq!(result["id"], 1);
1606        assert_eq!(result["name"], "Alice");
1607        assert!(result.get("secret").is_none());
1608    }
1609
1610    #[cfg(feature = "transform-select")]
1611    #[test]
1612    fn select_missing_field_is_no_op() {
1613        // Listed field is absent — must not introduce a null.
1614        let record = json!({"id": 1});
1615        let result = apply_all(
1616            record,
1617            &compiled(&[RecordTransform::Select {
1618                fields: vec!["id".into(), "missing".into()],
1619            }]),
1620        );
1621        assert_eq!(result["id"], 1);
1622        assert!(result.get("missing").is_none());
1623    }
1624
1625    #[cfg(feature = "transform-select")]
1626    #[test]
1627    fn select_passes_through_non_object() {
1628        let record = json!([1, 2, 3]);
1629        let result = apply_all(
1630            record.clone(),
1631            &compiled(&[RecordTransform::Select {
1632                fields: vec!["id".into()],
1633            }]),
1634        );
1635        assert_eq!(result, record);
1636    }
1637
1638    // ── Drop ──────────────────────────────────────────────────────────────────
1639
1640    #[cfg(feature = "transform-drop")]
1641    #[test]
1642    fn drop_removes_listed_fields() {
1643        let record = json!({"id": 1, "ssn": "111-22-3333", "name": "Alice"});
1644        let result = apply_all(
1645            record,
1646            &compiled(&[RecordTransform::Drop {
1647                fields: vec!["ssn".into()],
1648            }]),
1649        );
1650        assert_eq!(result["id"], 1);
1651        assert_eq!(result["name"], "Alice");
1652        assert!(result.get("ssn").is_none());
1653    }
1654
1655    #[cfg(feature = "transform-drop")]
1656    #[test]
1657    fn drop_missing_field_is_no_op() {
1658        let record = json!({"id": 1});
1659        let result = apply_all(
1660            record,
1661            &compiled(&[RecordTransform::Drop {
1662                fields: vec!["missing".into()],
1663            }]),
1664        );
1665        assert_eq!(result["id"], 1);
1666    }
1667
1668    // ── Set ───────────────────────────────────────────────────────────────────
1669
1670    #[cfg(feature = "transform-set")]
1671    #[test]
1672    fn set_inserts_new_fields() {
1673        let record = json!({"id": 1});
1674        let mut values = Map::new();
1675        values.insert("_source".into(), json!("api"));
1676        values.insert("ingested_at".into(), json!("2026-01-01"));
1677        let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1678        assert_eq!(result["id"], 1);
1679        assert_eq!(result["_source"], "api");
1680        assert_eq!(result["ingested_at"], "2026-01-01");
1681    }
1682
1683    #[cfg(feature = "transform-set")]
1684    #[test]
1685    fn set_overwrites_existing_field() {
1686        let record = json!({"_source": "old", "id": 1});
1687        let mut values = Map::new();
1688        values.insert("_source".into(), json!("new"));
1689        let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1690        assert_eq!(result["_source"], "new");
1691        assert_eq!(result["id"], 1);
1692    }
1693
1694    #[cfg(feature = "transform-set")]
1695    #[test]
1696    fn set_supports_any_json_value() {
1697        let record = json!({});
1698        let mut values = Map::new();
1699        values.insert("n".into(), json!(42));
1700        values.insert("b".into(), json!(true));
1701        values.insert("arr".into(), json!([1, 2]));
1702        values.insert("obj".into(), json!({"k": "v"}));
1703        values.insert("null".into(), Value::Null);
1704        let result = apply_all(record, &compiled(&[RecordTransform::Set { values }]));
1705        assert_eq!(result["n"], 42);
1706        assert_eq!(result["b"], true);
1707        assert_eq!(result["arr"], json!([1, 2]));
1708        assert_eq!(result["obj"]["k"], "v");
1709        assert_eq!(result["null"], Value::Null);
1710    }
1711
1712    // ── RenameField ───────────────────────────────────────────────────────────
1713
1714    #[cfg(feature = "transform-rename-field")]
1715    #[test]
1716    fn rename_field_renames_exact_key() {
1717        let record = json!({"old_name": 1, "keep": 2});
1718        let mut fields = HashMap::new();
1719        fields.insert("old_name".to_owned(), "new_name".to_owned());
1720        let result = apply_all(
1721            record,
1722            &compiled(&[RecordTransform::RenameField { fields }]),
1723        );
1724        assert_eq!(result["new_name"], 1);
1725        assert_eq!(result["keep"], 2);
1726        assert!(result.get("old_name").is_none());
1727    }
1728
1729    #[cfg(feature = "transform-rename-field")]
1730    #[test]
1731    fn rename_field_missing_source_is_no_op() {
1732        let record = json!({"id": 1});
1733        let mut fields = HashMap::new();
1734        fields.insert("missing".to_owned(), "renamed".to_owned());
1735        let result = apply_all(
1736            record,
1737            &compiled(&[RecordTransform::RenameField { fields }]),
1738        );
1739        assert_eq!(result["id"], 1);
1740        assert!(result.get("renamed").is_none());
1741    }
1742
1743    #[cfg(feature = "transform-rename-field")]
1744    #[test]
1745    fn rename_field_target_collision_errors() {
1746        let record = json!({"a": 1, "b": 2});
1747        let mut fields = HashMap::new();
1748        fields.insert("a".to_owned(), "b".to_owned());
1749        let err = super::apply_all(
1750            record,
1751            &compiled(&[RecordTransform::RenameField { fields }]),
1752        )
1753        .expect_err("collision must error, not overwrite");
1754        assert!(matches!(err, FaucetError::Transform(_)));
1755        assert!(format!("{err}").contains("'b'"), "{err}");
1756    }
1757
1758    #[cfg(feature = "transform-rename-field")]
1759    #[test]
1760    fn rename_field_swap_is_deterministic() {
1761        // A swap {a:b, b:a} must exchange the two values, never error or corrupt,
1762        // and must be stable across HashMap iteration orders (run repeatedly).
1763        for _ in 0..50 {
1764            let record = json!({"a": 1, "b": 2, "keep": 3});
1765            let mut fields = HashMap::new();
1766            fields.insert("a".to_owned(), "b".to_owned());
1767            fields.insert("b".to_owned(), "a".to_owned());
1768            let result = apply_all(
1769                record,
1770                &compiled(&[RecordTransform::RenameField { fields }]),
1771            );
1772            assert_eq!(result["a"], 2, "{result}");
1773            assert_eq!(result["b"], 1, "{result}");
1774            assert_eq!(result["keep"], 3);
1775        }
1776    }
1777
1778    #[cfg(feature = "transform-rename-field")]
1779    #[test]
1780    fn rename_field_chain_applies_against_original_snapshot() {
1781        // A chain {a:b, b:c} renames against the ORIGINAL record: a→b and b→c
1782        // both read pre-rename values, deterministically, for any iteration order.
1783        for _ in 0..50 {
1784            let record = json!({"a": 1, "b": 2});
1785            let mut fields = HashMap::new();
1786            fields.insert("a".to_owned(), "b".to_owned());
1787            fields.insert("b".to_owned(), "c".to_owned());
1788            let result = apply_all(
1789                record,
1790                &compiled(&[RecordTransform::RenameField { fields }]),
1791            );
1792            assert_eq!(result["b"], 1, "{result}");
1793            assert_eq!(result["c"], 2, "{result}");
1794            assert!(result.get("a").is_none(), "{result}");
1795        }
1796    }
1797
1798    #[cfg(feature = "transform-rename-field")]
1799    #[test]
1800    fn rename_field_two_sources_one_target_errors() {
1801        let record = json!({"a": 1, "b": 2});
1802        let mut fields = HashMap::new();
1803        fields.insert("a".to_owned(), "c".to_owned());
1804        fields.insert("b".to_owned(), "c".to_owned());
1805        let err = super::apply_all(
1806            record,
1807            &compiled(&[RecordTransform::RenameField { fields }]),
1808        )
1809        .expect_err("two renames to the same target must error");
1810        assert!(format!("{err}").contains("same target"), "{err}");
1811    }
1812
1813    // ── Cast ──────────────────────────────────────────────────────────────────
1814
1815    #[cfg(feature = "transform-cast")]
1816    fn cast_specs(field: &str, ty: CastType, on_error: CastOnError) -> Vec<RecordTransform> {
1817        let mut fields = HashMap::new();
1818        fields.insert(field.to_owned(), ty);
1819        vec![RecordTransform::Cast { fields, on_error }]
1820    }
1821
1822    #[cfg(feature = "transform-cast")]
1823    #[test]
1824    fn cast_string_to_int() {
1825        let record = json!({"age": "42"});
1826        let result = apply_all(
1827            record,
1828            &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
1829        );
1830        assert_eq!(result["age"], 42);
1831    }
1832
1833    #[cfg(feature = "transform-cast")]
1834    #[test]
1835    fn cast_whole_number_float_to_int_succeeds() {
1836        // A float with no fractional part and within i64 range converts.
1837        let record = json!({"n": 5.0});
1838        let result = apply_all(
1839            record,
1840            &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1841        );
1842        assert_eq!(result["n"], 5);
1843    }
1844
1845    #[cfg(feature = "transform-cast")]
1846    #[test]
1847    fn cast_fractional_float_to_int_errors_under_on_error_error() {
1848        // A fractional float must surface an error, not silently truncate to 3.
1849        let record = json!({"n": 3.9});
1850        let err = super::apply_all(
1851            record,
1852            &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1853        )
1854        .expect_err("a fractional float must not silently truncate to int");
1855        assert!(matches!(err, FaucetError::Transform(_)), "{err}");
1856    }
1857
1858    #[cfg(feature = "transform-cast")]
1859    #[test]
1860    fn cast_out_of_range_float_to_int_errors_under_on_error_error() {
1861        // A float beyond i64 range must error, not silently saturate to i64::MAX.
1862        let record = json!({"n": 1e30});
1863        let err = super::apply_all(
1864            record,
1865            &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
1866        )
1867        .expect_err("an out-of-range float must not silently saturate to i64::MAX");
1868        assert!(matches!(err, FaucetError::Transform(_)), "{err}");
1869    }
1870
1871    #[cfg(feature = "transform-cast")]
1872    #[test]
1873    fn cast_fractional_float_to_int_nulls_under_on_error_null() {
1874        let record = json!({"n": 3.9});
1875        let result = apply_all(
1876            record,
1877            &compiled(&cast_specs("n", CastType::Int, CastOnError::Null)),
1878        );
1879        assert_eq!(result["n"], Value::Null);
1880    }
1881
1882    #[cfg(feature = "transform-cast")]
1883    #[test]
1884    fn cast_string_to_float() {
1885        let record = json!({"price": "9.99"});
1886        let result = apply_all(
1887            record,
1888            &compiled(&cast_specs("price", CastType::Float, CastOnError::Error)),
1889        );
1890        assert_eq!(result["price"], 9.99);
1891    }
1892
1893    #[cfg(feature = "transform-cast")]
1894    #[test]
1895    fn cast_string_to_bool() {
1896        for input in ["true", "TRUE", "1", "yes"] {
1897            let record = json!({"flag": input});
1898            let result = apply_all(
1899                record,
1900                &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
1901            );
1902            assert_eq!(result["flag"], true, "input was {input:?}");
1903        }
1904        for input in ["false", "0", "no"] {
1905            let record = json!({"flag": input});
1906            let result = apply_all(
1907                record,
1908                &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
1909            );
1910            assert_eq!(result["flag"], false, "input was {input:?}");
1911        }
1912    }
1913
1914    #[cfg(feature = "transform-cast")]
1915    #[test]
1916    fn cast_number_to_string() {
1917        let record = json!({"id": 42});
1918        let result = apply_all(
1919            record,
1920            &compiled(&cast_specs("id", CastType::String, CastOnError::Error)),
1921        );
1922        assert_eq!(result["id"], "42");
1923    }
1924
1925    #[cfg(feature = "transform-cast")]
1926    #[test]
1927    fn cast_string_to_timestamp_normalises() {
1928        let record = json!({"ts": "2026-05-28T12:34:56+00:00"});
1929        let result = apply_all(
1930            record,
1931            &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
1932        );
1933        // `+00:00` normalises to `Z` via chrono's RFC 3339 emitter.
1934        assert_eq!(result["ts"], "2026-05-28T12:34:56Z");
1935    }
1936
1937    #[cfg(feature = "transform-cast")]
1938    #[test]
1939    fn cast_on_error_error_propagates() {
1940        let record = json!({"age": "not a number"});
1941        let err = super::apply_all(
1942            record,
1943            &compiled(&cast_specs("age", CastType::Int, CastOnError::Error)),
1944        )
1945        .expect_err("uncastable value must error under on_error=error");
1946        assert!(matches!(err, FaucetError::Transform(_)));
1947        assert!(format!("{err}").contains("'age'"), "{err}");
1948    }
1949
1950    #[cfg(feature = "transform-cast")]
1951    #[test]
1952    fn cast_on_error_null_replaces() {
1953        let record = json!({"age": "not a number"});
1954        let result = apply_all(
1955            record,
1956            &compiled(&cast_specs("age", CastType::Int, CastOnError::Null)),
1957        );
1958        assert_eq!(result["age"], Value::Null);
1959    }
1960
1961    #[cfg(feature = "transform-cast")]
1962    #[test]
1963    fn cast_on_error_skip_leaves_value() {
1964        let record = json!({"age": "not a number"});
1965        let result = apply_all(
1966            record,
1967            &compiled(&cast_specs("age", CastType::Int, CastOnError::Skip)),
1968        );
1969        assert_eq!(result["age"], "not a number");
1970    }
1971
1972    #[cfg(feature = "transform-cast")]
1973    #[test]
1974    fn cast_missing_field_is_no_op() {
1975        let record = json!({"id": 1});
1976        let result = apply_all(
1977            record,
1978            &compiled(&cast_specs("missing", CastType::Int, CastOnError::Error)),
1979        );
1980        assert_eq!(result["id"], 1);
1981        assert!(result.get("missing").is_none());
1982    }
1983
1984    // ── Redact ────────────────────────────────────────────────────────────────
1985
1986    #[cfg(feature = "transform-redact")]
1987    #[test]
1988    fn redact_replaces_value_with_mask() {
1989        let record = json!({"id": 1, "ssn": "111-22-3333", "email": "x@y.z"});
1990        let result = apply_all(
1991            record,
1992            &compiled(&[RecordTransform::Redact {
1993                fields: vec!["ssn".into(), "email".into()],
1994                mask: json!("***"),
1995            }]),
1996        );
1997        assert_eq!(result["id"], 1);
1998        assert_eq!(result["ssn"], "***");
1999        assert_eq!(result["email"], "***");
2000    }
2001
2002    #[cfg(feature = "transform-redact")]
2003    #[test]
2004    fn redact_missing_field_does_not_insert_mask() {
2005        let record = json!({"id": 1});
2006        let result = apply_all(
2007            record,
2008            &compiled(&[RecordTransform::Redact {
2009                fields: vec!["ssn".into()],
2010                mask: json!("***"),
2011            }]),
2012        );
2013        assert_eq!(result["id"], 1);
2014        assert!(result.get("ssn").is_none());
2015    }
2016
2017    // ── ValueCase ─────────────────────────────────────────────────────────────
2018
2019    #[cfg(feature = "transform-value-case")]
2020    #[test]
2021    fn value_case_lower() {
2022        let record = json!({"email": "User@Example.COM", "id": 1});
2023        let result = apply_all(
2024            record,
2025            &compiled(&[RecordTransform::ValueCase {
2026                fields: vec!["email".into()],
2027                mode: ValueCaseMode::Lower,
2028            }]),
2029        );
2030        assert_eq!(result["email"], "user@example.com");
2031        assert_eq!(result["id"], 1);
2032    }
2033
2034    #[cfg(feature = "transform-value-case")]
2035    #[test]
2036    fn value_case_upper() {
2037        let record = json!({"code": "abc"});
2038        let result = apply_all(
2039            record,
2040            &compiled(&[RecordTransform::ValueCase {
2041                fields: vec!["code".into()],
2042                mode: ValueCaseMode::Upper,
2043            }]),
2044        );
2045        assert_eq!(result["code"], "ABC");
2046    }
2047
2048    #[cfg(feature = "transform-value-case")]
2049    #[test]
2050    fn value_case_trim() {
2051        let record = json!({"name": "  Alice  "});
2052        let result = apply_all(
2053            record,
2054            &compiled(&[RecordTransform::ValueCase {
2055                fields: vec!["name".into()],
2056                mode: ValueCaseMode::Trim,
2057            }]),
2058        );
2059        assert_eq!(result["name"], "Alice");
2060    }
2061
2062    #[cfg(feature = "transform-value-case")]
2063    #[test]
2064    fn value_case_passes_non_string_through() {
2065        let record = json!({"id": 42});
2066        let result = apply_all(
2067            record,
2068            &compiled(&[RecordTransform::ValueCase {
2069                fields: vec!["id".into()],
2070                mode: ValueCaseMode::Upper,
2071            }]),
2072        );
2073        assert_eq!(result["id"], 42);
2074    }
2075
2076    // ── SpellSymbols ──────────────────────────────────────────────────────────
2077
2078    #[cfg(feature = "transform-spell-symbols")]
2079    fn spell_default() -> Vec<RecordTransform> {
2080        vec![RecordTransform::SpellSymbols {
2081            extra: HashMap::new(),
2082            separator: " ".into(),
2083        }]
2084    }
2085
2086    #[cfg(feature = "transform-spell-symbols")]
2087    #[test]
2088    fn spell_symbols_replaces_common_symbols() {
2089        let record = json!({"%sold": 1, "C#course": 2, "$amount": 3});
2090        let result = apply_all(record, &compiled(&spell_default()));
2091        // Defaults insert " " around each replacement so a downstream
2092        // snake_case picks up the word boundary.
2093        assert!(result.get(" percent sold").is_some());
2094        assert!(result.get("C number course").is_some());
2095        assert!(result.get(" dollar amount").is_some());
2096    }
2097
2098    #[cfg(all(feature = "transform-spell-symbols", feature = "transform-keys-case"))]
2099    #[test]
2100    fn spell_symbols_then_keys_case_pipeline() {
2101        let record = json!({"% sold": 10, "C# courses": 20});
2102        let result = super::apply_all(
2103            record,
2104            &compiled(&[
2105                RecordTransform::SpellSymbols {
2106                    extra: HashMap::new(),
2107                    separator: " ".into(),
2108                },
2109                RecordTransform::KeysCase {
2110                    mode: KeyCaseMode::Snake,
2111                },
2112            ]),
2113        )
2114        .expect("pipeline must succeed");
2115        assert_eq!(result["percent_sold"], 10);
2116        assert_eq!(result["c_number_courses"], 20);
2117    }
2118
2119    #[cfg(feature = "transform-spell-symbols")]
2120    #[test]
2121    fn spell_symbols_extra_overrides_defaults() {
2122        let mut extra = HashMap::new();
2123        extra.insert("#".to_owned(), "hash".to_owned());
2124        extra.insert("©".to_owned(), "copyright".to_owned());
2125        let record = json!({"#tag": 1, "©2026": 2});
2126        let result = apply_all(
2127            record,
2128            &compiled(&[RecordTransform::SpellSymbols {
2129                extra,
2130                separator: " ".into(),
2131            }]),
2132        );
2133        // `#` override beats the default `"number"`.
2134        assert!(result.get(" hash tag").is_some());
2135        // `©` is not in the default map but the user added it.
2136        assert!(result.get(" copyright 2026").is_some());
2137    }
2138
2139    #[cfg(feature = "transform-spell-symbols")]
2140    #[test]
2141    fn spell_symbols_longest_match_wins() {
2142        // Without longest-first ordering, `"<"` would shadow `"<="`.
2143        let mut extra = HashMap::new();
2144        extra.insert("<=".to_owned(), "lte".to_owned());
2145        let record = json!({"a<=b": 1});
2146        let result = apply_all(
2147            record,
2148            &compiled(&[RecordTransform::SpellSymbols {
2149                extra,
2150                separator: " ".into(),
2151            }]),
2152        );
2153        assert!(result.get("a lte b").is_some());
2154        // Confirm `<` alone was NOT applied separately.
2155        assert!(result.get("a lt = b").is_none());
2156    }
2157
2158    #[cfg(feature = "transform-spell-symbols")]
2159    #[test]
2160    fn spell_symbols_recursive_into_objects_and_arrays() {
2161        let record = json!({"outer&": {"inner%": [{"deep#": 1}]}});
2162        let result = apply_all(record, &compiled(&spell_default()));
2163        let outer_key = result.as_object().unwrap().keys().next().unwrap().clone();
2164        assert!(outer_key.contains("and"), "outer key was {outer_key:?}");
2165        let inner = &result[&outer_key];
2166        let inner_key = inner.as_object().unwrap().keys().next().unwrap().clone();
2167        assert!(inner_key.contains("percent"), "inner key was {inner_key:?}");
2168        let deep = &inner[&inner_key][0];
2169        let deep_key = deep.as_object().unwrap().keys().next().unwrap().clone();
2170        assert!(deep_key.contains("number"), "deep key was {deep_key:?}");
2171    }
2172
2173    #[cfg(feature = "transform-spell-symbols")]
2174    #[test]
2175    fn spell_symbols_key_collision_errors() {
2176        // With separator "" both keys collapse to "percent".
2177        let record = json!({"%": 1, "percent": 2});
2178        let err = super::apply_all(
2179            record,
2180            &compiled(&[RecordTransform::SpellSymbols {
2181                extra: HashMap::new(),
2182                separator: "".into(),
2183            }]),
2184        )
2185        .expect_err("colliding spelled keys must error, not drop a value");
2186        assert!(matches!(err, FaucetError::Transform(_)));
2187        assert!(format!("{err}").contains("percent"), "{err}");
2188    }
2189
2190    // ── KeysCase ──────────────────────────────────────────────────────────────
2191
2192    #[cfg(feature = "transform-keys-case")]
2193    fn keys_case_specs(mode: KeyCaseMode) -> Vec<RecordTransform> {
2194        vec![RecordTransform::KeysCase { mode }]
2195    }
2196
2197    #[cfg(feature = "transform-keys-case")]
2198    #[test]
2199    fn keys_case_snake() {
2200        let record = json!({"First Name": 1, "last-name": 2, "ID": 3});
2201        let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2202        assert_eq!(result["first_name"], 1);
2203        assert_eq!(result["last_name"], 2);
2204        assert_eq!(result["id"], 3);
2205    }
2206
2207    #[cfg(feature = "transform-keys-case")]
2208    #[test]
2209    fn keys_case_camel_from_various_inputs() {
2210        // snake → camel
2211        let record = json!({"first_name": 1, "User ID": 2, "kebab-case": 3, "PascalCase": 4});
2212        let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Camel)));
2213        assert_eq!(result["firstName"], 1);
2214        assert_eq!(result["userId"], 2);
2215        assert_eq!(result["kebabCase"], 3);
2216        assert_eq!(result["pascalCase"], 4);
2217    }
2218
2219    #[cfg(feature = "transform-keys-case")]
2220    #[test]
2221    fn keys_case_pascal() {
2222        let record = json!({"first_name": 1, "second name": 2});
2223        let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Pascal)));
2224        assert_eq!(result["FirstName"], 1);
2225        assert_eq!(result["SecondName"], 2);
2226    }
2227
2228    #[cfg(feature = "transform-keys-case")]
2229    #[test]
2230    fn keys_case_kebab() {
2231        let record = json!({"firstName": 1, "second_name": 2});
2232        let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Kebab)));
2233        assert_eq!(result["first-name"], 1);
2234        assert_eq!(result["second-name"], 2);
2235    }
2236
2237    #[cfg(feature = "transform-keys-case")]
2238    #[test]
2239    fn keys_case_screaming_snake() {
2240        let record = json!({"firstName": 1, "second name": 2});
2241        let result = apply_all(
2242            record,
2243            &compiled(&keys_case_specs(KeyCaseMode::ScreamingSnake)),
2244        );
2245        assert_eq!(result["FIRST_NAME"], 1);
2246        assert_eq!(result["SECOND_NAME"], 2);
2247    }
2248
2249    #[cfg(feature = "transform-keys-case")]
2250    #[test]
2251    fn keys_case_recursive_into_nested() {
2252        let record = json!({"User Info": {"First Name": "Alice", "items": [{"Tag Name": "x"}]}});
2253        let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2254        assert_eq!(result["user_info"]["first_name"], "Alice");
2255        assert_eq!(result["user_info"]["items"][0]["tag_name"], "x");
2256    }
2257
2258    #[cfg(feature = "transform-keys-case")]
2259    #[test]
2260    fn keys_case_collision_errors() {
2261        // "firstName" and "first_name" both snake_case to "first_name".
2262        let record = json!({"firstName": 1, "first_name": 2});
2263        let err = super::apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)))
2264            .expect_err("colliding re-cased keys must error, not drop a value");
2265        assert!(matches!(err, FaucetError::Transform(_)));
2266        assert!(format!("{err}").contains("first_name"), "{err}");
2267    }
2268
2269    #[cfg(feature = "transform-keys-case")]
2270    #[test]
2271    fn keys_case_all_symbol_key_kept_as_is() {
2272        // A key that tokenises to nothing must keep its original form rather
2273        // than producing an empty-string key.
2274        let record = json!({"!@#": 1, "id": 2});
2275        let result = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2276        assert_eq!(result["!@#"], 1);
2277        assert_eq!(result["id"], 2);
2278    }
2279
2280    #[cfg(feature = "transform-keys-case")]
2281    #[test]
2282    fn keys_case_idempotent_in_target_mode() {
2283        // Re-running the transform should be a no-op once keys are already in
2284        // the target shape.
2285        let record = json!({"first_name": 1});
2286        let once = apply_all(record, &compiled(&keys_case_specs(KeyCaseMode::Snake)));
2287        let twice = apply_all(
2288            once.clone(),
2289            &compiled(&keys_case_specs(KeyCaseMode::Snake)),
2290        );
2291        assert_eq!(once, twice);
2292    }
2293
2294    #[cfg(feature = "transform-spell-symbols")]
2295    #[test]
2296    fn spell_symbols_handles_unicode_keys() {
2297        // A non-ASCII char with a UTF-8 length > 1 must not corrupt the walk.
2298        let record = json!({"café%": 1});
2299        let result = apply_all(record, &compiled(&spell_default()));
2300        let key = result.as_object().unwrap().keys().next().unwrap().clone();
2301        assert!(key.contains("café"), "key was {key:?}");
2302        assert!(key.contains("percent"), "key was {key:?}");
2303    }
2304
2305    // ── Debug formatting for every RecordTransform variant ─────────────────────
2306
2307    #[test]
2308    fn debug_record_transform_all_variants() {
2309        // Custom is always available.
2310        let dbg = format!("{:?}", RecordTransform::custom(|v| v));
2311        assert_eq!(dbg, "Custom(<fn>)");
2312
2313        #[cfg(feature = "transform-flatten")]
2314        {
2315            let dbg = format!(
2316                "{:?}",
2317                RecordTransform::Flatten {
2318                    separator: "__".into()
2319                }
2320            );
2321            assert!(dbg.starts_with("Flatten"), "{dbg}");
2322            assert!(dbg.contains("separator"), "{dbg}");
2323            assert!(dbg.contains("__"), "{dbg}");
2324        }
2325        #[cfg(feature = "transform-rename-keys")]
2326        {
2327            let dbg = format!(
2328                "{:?}",
2329                RecordTransform::RenameKeys {
2330                    pattern: "p".into(),
2331                    replacement: "r".into(),
2332                }
2333            );
2334            assert!(dbg.starts_with("RenameKeys"), "{dbg}");
2335            assert!(dbg.contains("pattern"), "{dbg}");
2336            assert!(dbg.contains("replacement"), "{dbg}");
2337        }
2338        #[cfg(feature = "transform-keys-case")]
2339        {
2340            let dbg = format!(
2341                "{:?}",
2342                RecordTransform::KeysCase {
2343                    mode: KeyCaseMode::Snake
2344                }
2345            );
2346            assert!(dbg.starts_with("KeysCase"), "{dbg}");
2347            assert!(dbg.contains("Snake"), "{dbg}");
2348        }
2349        #[cfg(feature = "transform-select")]
2350        {
2351            let dbg = format!(
2352                "{:?}",
2353                RecordTransform::Select {
2354                    fields: vec!["a".into()]
2355                }
2356            );
2357            assert!(dbg.starts_with("Select"), "{dbg}");
2358            assert!(dbg.contains("fields"), "{dbg}");
2359        }
2360        #[cfg(feature = "transform-drop")]
2361        {
2362            let dbg = format!(
2363                "{:?}",
2364                RecordTransform::Drop {
2365                    fields: vec!["a".into()]
2366                }
2367            );
2368            assert!(dbg.starts_with("Drop"), "{dbg}");
2369        }
2370        #[cfg(feature = "transform-set")]
2371        {
2372            let mut values = Map::new();
2373            values.insert("k".into(), json!("v"));
2374            let dbg = format!("{:?}", RecordTransform::Set { values });
2375            assert!(dbg.starts_with("Set"), "{dbg}");
2376            assert!(dbg.contains("values"), "{dbg}");
2377        }
2378        #[cfg(feature = "transform-rename-field")]
2379        {
2380            let mut fields = HashMap::new();
2381            fields.insert("a".to_owned(), "b".to_owned());
2382            let dbg = format!("{:?}", RecordTransform::RenameField { fields });
2383            assert!(dbg.starts_with("RenameField"), "{dbg}");
2384        }
2385        #[cfg(feature = "transform-cast")]
2386        {
2387            let mut fields = HashMap::new();
2388            fields.insert("a".to_owned(), CastType::Int);
2389            let dbg = format!(
2390                "{:?}",
2391                RecordTransform::Cast {
2392                    fields,
2393                    on_error: CastOnError::Error,
2394                }
2395            );
2396            assert!(dbg.starts_with("Cast"), "{dbg}");
2397            assert!(dbg.contains("on_error"), "{dbg}");
2398        }
2399        #[cfg(feature = "transform-redact")]
2400        {
2401            let dbg = format!(
2402                "{:?}",
2403                RecordTransform::Redact {
2404                    fields: vec!["a".into()],
2405                    mask: json!("***"),
2406                }
2407            );
2408            assert!(dbg.starts_with("Redact"), "{dbg}");
2409            assert!(dbg.contains("mask"), "{dbg}");
2410        }
2411        #[cfg(feature = "transform-value-case")]
2412        {
2413            let dbg = format!(
2414                "{:?}",
2415                RecordTransform::ValueCase {
2416                    fields: vec!["a".into()],
2417                    mode: ValueCaseMode::Lower,
2418                }
2419            );
2420            assert!(dbg.starts_with("ValueCase"), "{dbg}");
2421            assert!(dbg.contains("mode"), "{dbg}");
2422        }
2423        #[cfg(feature = "transform-spell-symbols")]
2424        {
2425            let dbg = format!(
2426                "{:?}",
2427                RecordTransform::SpellSymbols {
2428                    extra: HashMap::new(),
2429                    separator: " ".into(),
2430                }
2431            );
2432            assert!(dbg.starts_with("SpellSymbols"), "{dbg}");
2433            assert!(dbg.contains("separator"), "{dbg}");
2434        }
2435    }
2436
2437    // ── Clone for every RecordTransform variant (refcount bump on Custom) ──────
2438
2439    #[test]
2440    fn clone_record_transform_custom_preserves_behaviour() {
2441        let original = RecordTransform::custom(|mut v| {
2442            if let Value::Object(ref mut m) = v {
2443                m.insert("cloned".into(), json!(true));
2444            }
2445            v
2446        });
2447        let cloned = original.clone();
2448        assert_eq!(format!("{cloned:?}"), "Custom(<fn>)");
2449        let out = apply_all(json!({"id": 1}), &compiled(&[cloned]));
2450        assert_eq!(out["cloned"], true);
2451        assert_eq!(out["id"], 1);
2452    }
2453
2454    #[test]
2455    // Every push below is #[cfg(feature)]-gated, so a vec![] literal can't
2456    // express this; suppress the vec-init-then-push lint for the whole test.
2457    #[allow(clippy::vec_init_then_push)]
2458    fn clone_record_transform_all_builtin_variants() {
2459        let mut variants: Vec<RecordTransform> = Vec::new();
2460        #[cfg(feature = "transform-flatten")]
2461        variants.push(RecordTransform::Flatten {
2462            separator: "__".into(),
2463        });
2464        #[cfg(feature = "transform-rename-keys")]
2465        variants.push(RecordTransform::RenameKeys {
2466            pattern: "p".into(),
2467            replacement: "r".into(),
2468        });
2469        #[cfg(feature = "transform-keys-case")]
2470        variants.push(RecordTransform::KeysCase {
2471            mode: KeyCaseMode::Snake,
2472        });
2473        #[cfg(feature = "transform-select")]
2474        variants.push(RecordTransform::Select {
2475            fields: vec!["a".into()],
2476        });
2477        #[cfg(feature = "transform-drop")]
2478        variants.push(RecordTransform::Drop {
2479            fields: vec!["a".into()],
2480        });
2481        #[cfg(feature = "transform-set")]
2482        {
2483            let mut values = Map::new();
2484            values.insert("k".into(), json!("v"));
2485            variants.push(RecordTransform::Set { values });
2486        }
2487        #[cfg(feature = "transform-rename-field")]
2488        {
2489            let mut fields = HashMap::new();
2490            fields.insert("a".to_owned(), "b".to_owned());
2491            variants.push(RecordTransform::RenameField { fields });
2492        }
2493        #[cfg(feature = "transform-cast")]
2494        {
2495            let mut fields = HashMap::new();
2496            fields.insert("a".to_owned(), CastType::Int);
2497            variants.push(RecordTransform::Cast {
2498                fields,
2499                on_error: CastOnError::Error,
2500            });
2501        }
2502        #[cfg(feature = "transform-redact")]
2503        variants.push(RecordTransform::Redact {
2504            fields: vec!["a".into()],
2505            mask: json!("***"),
2506        });
2507        #[cfg(feature = "transform-value-case")]
2508        variants.push(RecordTransform::ValueCase {
2509            fields: vec!["a".into()],
2510            mode: ValueCaseMode::Lower,
2511        });
2512        #[cfg(feature = "transform-spell-symbols")]
2513        variants.push(RecordTransform::SpellSymbols {
2514            extra: HashMap::new(),
2515            separator: " ".into(),
2516        });
2517
2518        // The clone's Debug must match the original's Debug exactly.
2519        for v in &variants {
2520            let cloned = v.clone();
2521            assert_eq!(format!("{v:?}"), format!("{cloned:?}"));
2522        }
2523    }
2524
2525    #[test]
2526    fn clone_compiled_transform_all_variants() {
2527        let mut specs: Vec<RecordTransform> = vec![RecordTransform::custom(|v| v)];
2528        #[cfg(feature = "transform-flatten")]
2529        specs.push(RecordTransform::Flatten {
2530            separator: "__".into(),
2531        });
2532        #[cfg(feature = "transform-rename-keys")]
2533        specs.push(RecordTransform::RenameKeys {
2534            pattern: "p".into(),
2535            replacement: "r".into(),
2536        });
2537        #[cfg(feature = "transform-keys-case")]
2538        specs.push(RecordTransform::KeysCase {
2539            mode: KeyCaseMode::Camel,
2540        });
2541        #[cfg(feature = "transform-select")]
2542        specs.push(RecordTransform::Select {
2543            fields: vec!["a".into()],
2544        });
2545        #[cfg(feature = "transform-drop")]
2546        specs.push(RecordTransform::Drop {
2547            fields: vec!["a".into()],
2548        });
2549        #[cfg(feature = "transform-set")]
2550        {
2551            let mut values = Map::new();
2552            values.insert("k".into(), json!("v"));
2553            specs.push(RecordTransform::Set { values });
2554        }
2555        #[cfg(feature = "transform-rename-field")]
2556        {
2557            let mut fields = HashMap::new();
2558            fields.insert("a".to_owned(), "b".to_owned());
2559            specs.push(RecordTransform::RenameField { fields });
2560        }
2561        #[cfg(feature = "transform-cast")]
2562        {
2563            let mut fields = HashMap::new();
2564            fields.insert("a".to_owned(), CastType::Int);
2565            specs.push(RecordTransform::Cast {
2566                fields,
2567                on_error: CastOnError::Null,
2568            });
2569        }
2570        #[cfg(feature = "transform-redact")]
2571        specs.push(RecordTransform::Redact {
2572            fields: vec!["a".into()],
2573            mask: json!("***"),
2574        });
2575        #[cfg(feature = "transform-value-case")]
2576        specs.push(RecordTransform::ValueCase {
2577            fields: vec!["a".into()],
2578            mode: ValueCaseMode::Upper,
2579        });
2580        #[cfg(feature = "transform-spell-symbols")]
2581        specs.push(RecordTransform::SpellSymbols {
2582            extra: HashMap::new(),
2583            separator: " ".into(),
2584        });
2585
2586        // Compile each, clone the compiled form, and confirm the cloned slice
2587        // still transforms a record identically to the original slice.
2588        let original = compiled(&specs);
2589        let cloned: Vec<CompiledTransform> = original.to_vec();
2590        assert_eq!(original.len(), cloned.len());
2591        let record = json!({"a": "1", "k": "x"});
2592        let out_orig = super::apply_all(record.clone(), &original);
2593        let out_clone = super::apply_all(record, &cloned);
2594        assert_eq!(
2595            out_orig.is_ok(),
2596            out_clone.is_ok(),
2597            "clone must transform identically"
2598        );
2599        if let (Ok(a), Ok(b)) = (out_orig, out_clone) {
2600            assert_eq!(a, b);
2601        }
2602    }
2603
2604    // ── Non-object records pass through every object-only transform ────────────
2605
2606    #[cfg(feature = "transform-flatten")]
2607    #[test]
2608    fn flatten_passes_through_non_object() {
2609        let record = json!([1, 2, 3]);
2610        let result = apply_all(
2611            record.clone(),
2612            &compiled(&[RecordTransform::Flatten {
2613                separator: "__".into(),
2614            }]),
2615        );
2616        assert_eq!(result, record);
2617        // A bare scalar too.
2618        let scalar = json!(42);
2619        let result = apply_all(
2620            scalar.clone(),
2621            &compiled(&[RecordTransform::Flatten {
2622                separator: "__".into(),
2623            }]),
2624        );
2625        assert_eq!(result, scalar);
2626    }
2627
2628    #[cfg(feature = "transform-drop")]
2629    #[test]
2630    fn drop_passes_through_non_object() {
2631        let record = json!([1, 2]);
2632        let result = apply_all(
2633            record.clone(),
2634            &compiled(&[RecordTransform::Drop {
2635                fields: vec!["a".into()],
2636            }]),
2637        );
2638        assert_eq!(result, record);
2639    }
2640
2641    #[cfg(feature = "transform-set")]
2642    #[test]
2643    fn set_passes_through_non_object() {
2644        let mut values = Map::new();
2645        values.insert("k".into(), json!("v"));
2646        let record = json!("scalar");
2647        let result = apply_all(
2648            record.clone(),
2649            &compiled(&[RecordTransform::Set { values }]),
2650        );
2651        assert_eq!(result, record);
2652    }
2653
2654    #[cfg(feature = "transform-rename-field")]
2655    #[test]
2656    fn rename_field_passes_through_non_object() {
2657        let mut fields = HashMap::new();
2658        fields.insert("a".to_owned(), "b".to_owned());
2659        let record = json!([1, 2]);
2660        let result = apply_all(
2661            record.clone(),
2662            &compiled(&[RecordTransform::RenameField { fields }]),
2663        );
2664        assert_eq!(result, record);
2665    }
2666
2667    #[cfg(feature = "transform-rename-field")]
2668    #[test]
2669    fn rename_field_same_name_is_skipped() {
2670        // from == to short-circuits (continue) and leaves the field intact.
2671        let mut fields = HashMap::new();
2672        fields.insert("a".to_owned(), "a".to_owned());
2673        let record = json!({"a": 1});
2674        let result = apply_all(
2675            record,
2676            &compiled(&[RecordTransform::RenameField { fields }]),
2677        );
2678        assert_eq!(result["a"], 1);
2679    }
2680
2681    #[cfg(feature = "transform-cast")]
2682    #[test]
2683    fn cast_passes_through_non_object() {
2684        let record = json!([1, 2]);
2685        let result = apply_all(
2686            record.clone(),
2687            &compiled(&cast_specs("a", CastType::Int, CastOnError::Error)),
2688        );
2689        assert_eq!(result, record);
2690    }
2691
2692    #[cfg(feature = "transform-redact")]
2693    #[test]
2694    fn redact_passes_through_non_object() {
2695        let record = json!("scalar");
2696        let result = apply_all(
2697            record.clone(),
2698            &compiled(&[RecordTransform::Redact {
2699                fields: vec!["a".into()],
2700                mask: json!("***"),
2701            }]),
2702        );
2703        assert_eq!(result, record);
2704    }
2705
2706    #[cfg(feature = "transform-value-case")]
2707    #[test]
2708    fn value_case_passes_through_non_object() {
2709        let record = json!([1, 2]);
2710        let result = apply_all(
2711            record.clone(),
2712            &compiled(&[RecordTransform::ValueCase {
2713                fields: vec!["a".into()],
2714                mode: ValueCaseMode::Lower,
2715            }]),
2716        );
2717        assert_eq!(result, record);
2718    }
2719
2720    // ── Cast: exhaustive per-type / per-source-value matrix ────────────────────
2721
2722    #[cfg(feature = "transform-cast")]
2723    #[test]
2724    fn cast_integer_number_to_int_is_identity() {
2725        // Number that is already an i64 takes the `as_i64()` Some branch.
2726        let record = json!({"n": 7});
2727        let result = apply_all(
2728            record,
2729            &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2730        );
2731        assert_eq!(result["n"], 7);
2732    }
2733
2734    #[cfg(feature = "transform-cast")]
2735    #[test]
2736    fn cast_bool_to_int() {
2737        let record = json!({"t": true, "f": false});
2738        let mut fields = HashMap::new();
2739        fields.insert("t".to_owned(), CastType::Int);
2740        fields.insert("f".to_owned(), CastType::Int);
2741        let result = apply_all(
2742            record,
2743            &compiled(&[RecordTransform::Cast {
2744                fields,
2745                on_error: CastOnError::Error,
2746            }]),
2747        );
2748        assert_eq!(result["t"], 1);
2749        assert_eq!(result["f"], 0);
2750    }
2751
2752    #[cfg(feature = "transform-cast")]
2753    #[test]
2754    fn cast_null_to_int_errors() {
2755        let record = json!({"n": null});
2756        let err = super::apply_all(
2757            record,
2758            &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2759        )
2760        .expect_err("null cannot become int");
2761        assert!(
2762            format!("{err}").contains("null cannot be cast to int"),
2763            "{err}"
2764        );
2765    }
2766
2767    #[cfg(feature = "transform-cast")]
2768    #[test]
2769    fn cast_composite_to_int_errors() {
2770        let record = json!({"n": [1, 2]});
2771        let err = super::apply_all(
2772            record,
2773            &compiled(&cast_specs("n", CastType::Int, CastOnError::Error)),
2774        )
2775        .expect_err("array cannot become int");
2776        assert!(format!("{err}").contains("composite"), "{err}");
2777    }
2778
2779    #[cfg(feature = "transform-cast")]
2780    #[test]
2781    fn cast_number_to_float() {
2782        let record = json!({"n": 5});
2783        let result = apply_all(
2784            record,
2785            &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2786        );
2787        assert_eq!(result["n"], 5.0);
2788    }
2789
2790    #[cfg(feature = "transform-cast")]
2791    #[test]
2792    fn cast_bool_to_float() {
2793        let record = json!({"t": true, "f": false});
2794        let mut fields = HashMap::new();
2795        fields.insert("t".to_owned(), CastType::Float);
2796        fields.insert("f".to_owned(), CastType::Float);
2797        let result = apply_all(
2798            record,
2799            &compiled(&[RecordTransform::Cast {
2800                fields,
2801                on_error: CastOnError::Error,
2802            }]),
2803        );
2804        assert_eq!(result["t"], 1.0);
2805        assert_eq!(result["f"], 0.0);
2806    }
2807
2808    #[cfg(feature = "transform-cast")]
2809    #[test]
2810    fn cast_null_to_float_errors() {
2811        let record = json!({"n": null});
2812        let err = super::apply_all(
2813            record,
2814            &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2815        )
2816        .expect_err("null cannot become float");
2817        assert!(
2818            format!("{err}").contains("null cannot be cast to float"),
2819            "{err}"
2820        );
2821    }
2822
2823    #[cfg(feature = "transform-cast")]
2824    #[test]
2825    fn cast_composite_to_float_errors() {
2826        let record = json!({"n": {"x": 1}});
2827        let err = super::apply_all(
2828            record,
2829            &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2830        )
2831        .expect_err("object cannot become float");
2832        assert!(format!("{err}").contains("composite"), "{err}");
2833    }
2834
2835    #[cfg(feature = "transform-cast")]
2836    #[test]
2837    fn cast_string_to_float_invalid_errors() {
2838        let record = json!({"n": "not a float"});
2839        let err = super::apply_all(
2840            record,
2841            &compiled(&cast_specs("n", CastType::Float, CastOnError::Error)),
2842        )
2843        .expect_err("non-numeric string cannot become float");
2844        assert!(format!("{err}").contains("is not a float"), "{err}");
2845    }
2846
2847    #[cfg(feature = "transform-cast")]
2848    #[test]
2849    fn cast_bool_to_bool_is_identity() {
2850        let record = json!({"b": true});
2851        let result = apply_all(
2852            record,
2853            &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
2854        );
2855        assert_eq!(result["b"], true);
2856    }
2857
2858    #[cfg(feature = "transform-cast")]
2859    #[test]
2860    fn cast_number_to_bool() {
2861        let record = json!({"on": 1, "off": 0});
2862        let mut fields = HashMap::new();
2863        fields.insert("on".to_owned(), CastType::Bool);
2864        fields.insert("off".to_owned(), CastType::Bool);
2865        let result = apply_all(
2866            record,
2867            &compiled(&[RecordTransform::Cast {
2868                fields,
2869                on_error: CastOnError::Error,
2870            }]),
2871        );
2872        assert_eq!(result["on"], true);
2873        assert_eq!(result["off"], false);
2874    }
2875
2876    #[cfg(feature = "transform-cast")]
2877    #[test]
2878    fn cast_integer_other_than_zero_one_to_bool_errors() {
2879        let record = json!({"n": 7});
2880        let err = super::apply_all(
2881            record,
2882            &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
2883        )
2884        .expect_err("only 0/1 convert to bool");
2885        assert!(format!("{err}").contains("not 0 or 1"), "{err}");
2886    }
2887
2888    #[cfg(feature = "transform-cast")]
2889    #[test]
2890    fn cast_float_number_to_bool_errors() {
2891        // A fractional number takes the non-i64 branch ("number ... is not 0 or 1").
2892        let record = json!({"n": 1.5});
2893        let err = super::apply_all(
2894            record,
2895            &compiled(&cast_specs("n", CastType::Bool, CastOnError::Error)),
2896        )
2897        .expect_err("fractional number cannot become bool");
2898        assert!(format!("{err}").contains("not 0 or 1"), "{err}");
2899    }
2900
2901    #[cfg(feature = "transform-cast")]
2902    #[test]
2903    fn cast_unrecognised_string_to_bool_errors() {
2904        let record = json!({"flag": "maybe"});
2905        let err = super::apply_all(
2906            record,
2907            &compiled(&cast_specs("flag", CastType::Bool, CastOnError::Error)),
2908        )
2909        .expect_err("'maybe' is not a boolean");
2910        assert!(
2911            format!("{err}").contains("not a recognised boolean"),
2912            "{err}"
2913        );
2914    }
2915
2916    #[cfg(feature = "transform-cast")]
2917    #[test]
2918    fn cast_null_to_bool_errors() {
2919        let record = json!({"b": null});
2920        let err = super::apply_all(
2921            record,
2922            &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
2923        )
2924        .expect_err("null cannot become bool");
2925        assert!(
2926            format!("{err}").contains("null cannot be cast to bool"),
2927            "{err}"
2928        );
2929    }
2930
2931    #[cfg(feature = "transform-cast")]
2932    #[test]
2933    fn cast_composite_to_bool_errors() {
2934        let record = json!({"b": [true]});
2935        let err = super::apply_all(
2936            record,
2937            &compiled(&cast_specs("b", CastType::Bool, CastOnError::Error)),
2938        )
2939        .expect_err("array cannot become bool");
2940        assert!(format!("{err}").contains("composite"), "{err}");
2941    }
2942
2943    #[cfg(feature = "transform-cast")]
2944    #[test]
2945    fn cast_string_to_string_is_identity() {
2946        let record = json!({"s": "hello"});
2947        let result = apply_all(
2948            record,
2949            &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
2950        );
2951        assert_eq!(result["s"], "hello");
2952    }
2953
2954    #[cfg(feature = "transform-cast")]
2955    #[test]
2956    fn cast_bool_to_string() {
2957        let record = json!({"b": true});
2958        let result = apply_all(
2959            record,
2960            &compiled(&cast_specs("b", CastType::String, CastOnError::Error)),
2961        );
2962        assert_eq!(result["b"], "true");
2963    }
2964
2965    #[cfg(feature = "transform-cast")]
2966    #[test]
2967    fn cast_null_to_string_errors() {
2968        let record = json!({"s": null});
2969        let err = super::apply_all(
2970            record,
2971            &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
2972        )
2973        .expect_err("null cannot become string");
2974        assert!(
2975            format!("{err}").contains("null cannot be cast to string"),
2976            "{err}"
2977        );
2978    }
2979
2980    #[cfg(feature = "transform-cast")]
2981    #[test]
2982    fn cast_composite_to_string_errors() {
2983        let record = json!({"s": {"a": 1}});
2984        let err = super::apply_all(
2985            record,
2986            &compiled(&cast_specs("s", CastType::String, CastOnError::Error)),
2987        )
2988        .expect_err("object cannot become string");
2989        assert!(format!("{err}").contains("composite"), "{err}");
2990    }
2991
2992    #[cfg(feature = "transform-cast")]
2993    #[test]
2994    fn cast_invalid_timestamp_string_errors() {
2995        let record = json!({"ts": "not a date"});
2996        let err = super::apply_all(
2997            record,
2998            &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
2999        )
3000        .expect_err("invalid timestamp string");
3001        assert!(format!("{err}").contains("RFC 3339"), "{err}");
3002    }
3003
3004    #[cfg(feature = "transform-cast")]
3005    #[test]
3006    fn cast_non_string_to_timestamp_names_the_type() {
3007        // Each non-string source exercises a distinct arm of value_type_name.
3008        for (val, ty_name) in [
3009            (json!(null), "null"),
3010            (json!(true), "bool"),
3011            (json!(42), "number"),
3012            (json!([1, 2]), "array"),
3013            (json!({"a": 1}), "object"),
3014        ] {
3015            let record = json!({ "ts": val });
3016            let err = super::apply_all(
3017                record,
3018                &compiled(&cast_specs("ts", CastType::Timestamp, CastOnError::Error)),
3019            )
3020            .expect_err("non-string cannot become timestamp");
3021            let msg = format!("{err}");
3022            assert!(msg.contains("timestamp"), "{msg}");
3023            assert!(
3024                msg.contains(ty_name),
3025                "expected type name {ty_name:?} in: {msg}"
3026            );
3027        }
3028    }
3029}