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