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