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