Skip to main content

faucet_core/
transform.rs

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