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