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