Skip to main content

faucet_cli/
transforms.rs

1//! Compile YAML/JSON transform declarations into `TransformStage` values.
2//!
3//! The built-in transforms are exposed via config — custom closure
4//! transforms require Rust code and are reserved for the library API.
5
6use crate::config::TransformSpec;
7use crate::error::{CliError, CliResult};
8#[cfg(feature = "transform-zip-columns")]
9use faucet_core::ZipColumnsSpec;
10#[cfg(feature = "transforms")]
11use faucet_core::{
12    CastOnError, CastType, CrossJoinSpec, HashAlgorithm, HashEncoding, JsonParseOnError,
13    KeyCaseMode, LookupOnMissing, TreeFlattenSpec, UnpivotSpec, ValueCaseMode,
14};
15#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
16use faucet_core::{JsonSchema, schema_for};
17use faucet_core::{RecordTransform, TransformStage};
18#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
19use serde::Deserialize;
20use serde_json::Value;
21#[cfg(feature = "transforms")]
22use std::collections::HashMap;
23
24/// Inline-config schema for the `flatten` transform.
25#[cfg(feature = "transforms")]
26#[derive(Debug, Deserialize, JsonSchema)]
27struct FlattenConfig {
28    /// Separator joining nested keys (default: `"__"`).
29    #[serde(default = "default_separator")]
30    separator: String,
31}
32
33#[cfg(feature = "transforms")]
34fn default_separator() -> String {
35    "__".to_owned()
36}
37
38/// Inline-config schema for the `rename_keys` transform.
39#[cfg(feature = "transforms")]
40#[derive(Debug, Deserialize, JsonSchema)]
41struct RenameKeysConfig {
42    /// Rust regex matched against every key.
43    pattern: String,
44    /// Replacement string. May reference capture groups (`$1`, `${name}`).
45    replacement: String,
46}
47
48#[cfg(feature = "transforms")]
49#[derive(Debug, Deserialize, JsonSchema)]
50struct FieldsConfig {
51    /// Top-level field names to act on.
52    fields: Vec<String>,
53}
54
55#[cfg(feature = "transforms")]
56#[derive(Debug, Deserialize, JsonSchema)]
57struct SetConfig {
58    /// Map of field name → constant value to set on every record.
59    values: serde_json::Map<String, Value>,
60}
61
62#[cfg(feature = "transforms")]
63#[derive(Debug, Deserialize, JsonSchema)]
64struct RenameFieldConfig {
65    /// Map of old field name → new field name.
66    fields: HashMap<String, String>,
67}
68
69#[cfg(feature = "transforms")]
70#[derive(Debug, Deserialize, JsonSchema)]
71struct CastConfig {
72    /// Map of field name → target type.
73    fields: HashMap<String, CastType>,
74    /// What to do when a value cannot be cast. Default: `error`.
75    #[serde(default)]
76    on_error: CastOnError,
77}
78
79#[cfg(feature = "transforms")]
80#[derive(Debug, Deserialize, JsonSchema)]
81struct RedactConfig {
82    /// Top-level field names to overwrite with `mask`.
83    fields: Vec<String>,
84    /// Replacement value. Default: the string `"***"`.
85    #[serde(default = "default_mask")]
86    mask: Value,
87}
88
89#[cfg(feature = "transforms")]
90fn default_mask() -> Value {
91    Value::String("***".to_owned())
92}
93
94#[cfg(feature = "transforms")]
95#[derive(Debug, Deserialize, JsonSchema)]
96struct ValueCaseConfig {
97    /// String-valued fields to re-case.
98    fields: Vec<String>,
99    /// Casing convention to apply to each listed field.
100    mode: ValueCaseMode,
101}
102
103#[cfg(feature = "transforms")]
104#[derive(Debug, Deserialize, JsonSchema)]
105struct SpellSymbolsConfig {
106    /// Extra symbol → word overrides layered on top of the built-in map.
107    #[serde(default)]
108    extra: HashMap<String, String>,
109    /// String inserted between expanded words. Default: a single space.
110    #[serde(default = "default_spell_separator")]
111    separator: String,
112}
113
114#[cfg(feature = "transforms")]
115fn default_spell_separator() -> String {
116    " ".to_owned()
117}
118
119#[cfg(feature = "transforms")]
120#[derive(Debug, Deserialize, JsonSchema)]
121struct KeysCaseConfig {
122    /// Output convention for every key in the record.
123    mode: KeyCaseMode,
124}
125
126#[cfg(feature = "transforms")]
127#[derive(Debug, Deserialize, JsonSchema)]
128struct HashConfig {
129    /// One or more field names to hash. When `into` is set, exactly one.
130    fields: Vec<String>,
131    /// Hash algorithm. Default: `sha256`.
132    #[serde(default)]
133    algorithm: HashAlgorithm,
134    /// Digest encoding. Default: `hex`.
135    #[serde(default)]
136    encoding: HashEncoding,
137    /// Optional salt prepended before hashing. Recommend `${env:...}`.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    salt: Option<String>,
140    /// Optional target key for the digest (source left intact). `null` =
141    /// replace in place. Only valid with a single field.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    into: Option<String>,
144}
145
146#[cfg(feature = "transforms")]
147#[derive(Debug, Deserialize, JsonSchema)]
148struct JsonParseConfig {
149    /// Field names holding stringified JSON to parse.
150    fields: Vec<String>,
151    /// What to do when a value is a string but not valid JSON. Default: `keep`.
152    #[serde(default)]
153    on_error: JsonParseOnError,
154    /// Optional target key for the parsed value. `null` = replace in place.
155    /// Only valid with a single field.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    into: Option<String>,
158}
159
160#[cfg(feature = "transforms")]
161#[derive(Debug, Deserialize, JsonSchema)]
162struct CoalesceConfig {
163    /// Target field to fill when missing or null.
164    field: String,
165    /// Literal default value. Mutually exclusive with `from`.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    default: Option<Value>,
168    /// Fallback keys; the first non-null wins. Mutually exclusive with `default`.
169    #[serde(default)]
170    from: Vec<String>,
171    /// Treat an empty string as null. Default: `false`.
172    #[serde(default)]
173    treat_empty_string_as_null: bool,
174}
175
176#[cfg(feature = "transforms")]
177#[derive(Debug, Deserialize, JsonSchema)]
178struct SplitConfig {
179    /// String field to split into an array.
180    field: String,
181    /// Delimiter to split on.
182    delimiter: String,
183    /// Trim whitespace from each element. Default: `false`.
184    #[serde(default)]
185    trim: bool,
186    /// Optional target key for the array. `null` = replace in place.
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    into: Option<String>,
189}
190
191#[cfg(feature = "transforms")]
192#[derive(Debug, Deserialize, JsonSchema)]
193struct JoinConfig {
194    /// Array field to join into a string.
195    field: String,
196    /// Delimiter placed between elements.
197    delimiter: String,
198    /// Optional target key for the string. `null` = replace in place.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    into: Option<String>,
201}
202
203#[cfg(feature = "transform-filter")]
204#[derive(Debug, Deserialize, JsonSchema)]
205struct FilterConfig {
206    /// JSONPath subset: bare key, dot path, or bracketed string key.
207    path: String,
208    /// One of `eq`, `ne`, `exists`, `in`, `not_in`.
209    op: faucet_core::FilterOp,
210    /// Required for `eq`/`ne`/`in`/`not_in`. For `in`/`not_in`, must be an array.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    value: Option<Value>,
213}
214
215#[cfg(feature = "transform-explode")]
216#[derive(Debug, Deserialize, JsonSchema)]
217struct ExplodeConfig {
218    /// JSONPath subset: bare key, dot path, or bracketed string key.
219    path: String,
220    /// Prefix prepended to object-element fields. Defaults to the last
221    /// segment of `path`. Empty string = pure LATERAL FLATTEN (no prefix).
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    prefix: Option<String>,
224    /// Separator between prefix and element field key. Default `"_"`.
225    #[serde(default = "default_explode_separator_cli")]
226    separator: String,
227    /// `passthrough` (default), `drop`, or `error` when path doesn't yield a
228    /// non-empty array.
229    #[serde(default)]
230    on_missing: faucet_core::OnMissing,
231    /// Copy named fields from the parent record onto every exploded child
232    /// (#555): `{ dest_field: "source.dot.path" }`. The source is a dot path
233    /// into the *parent* record (a leading `$.`/`$` is accepted). Useful when
234    /// the exploded array is nested and the child rows need a parent key (e.g.
235    /// `employee_id: "id"`) to stay joinable. Absent ⇒ standard explode.
236    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
237    carry: HashMap<String, String>,
238}
239
240#[cfg(feature = "transform-explode")]
241fn default_explode_separator_cli() -> String {
242    "_".to_owned()
243}
244
245/// Resolve a dot path (`id`, `a.b.c`, a leading `$.`/`$` accepted) against a
246/// record for `explode`'s `carry` (#555). Returns `None` if any segment is
247/// missing.
248#[cfg(feature = "transform-explode")]
249fn get_dot_path(rec: &Value, path: &str) -> Option<Value> {
250    let p = path.trim().trim_start_matches('$').trim_start_matches('.');
251    let mut cur = rec;
252    for seg in p.split('.').filter(|s| !s.is_empty()) {
253        cur = cur.get(seg)?;
254    }
255    Some(cur.clone())
256}
257
258#[cfg(feature = "transform-cdc-unwrap")]
259#[derive(Debug, Deserialize, JsonSchema)]
260struct CdcUnwrapConfig {
261    /// Envelope field holding the operation code. Default `"op"`.
262    #[serde(default = "cdc_op_field")]
263    op_field: String,
264    /// Envelope field holding the post-image row. Default `"after"`.
265    #[serde(default = "cdc_after_field")]
266    after_field: String,
267    /// Envelope field holding the pre-image row. Default `"before"`.
268    #[serde(default = "cdc_before_field")]
269    before_field: String,
270    /// Fallback key field for deletes when `before` is absent. Default `"document_key"`.
271    #[serde(default = "cdc_key_field")]
272    key_field: String,
273    /// Field stamped onto every emitted row with the op value. Default `"__op"`.
274    #[serde(default = "cdc_marker_field")]
275    marker_field: String,
276    /// Op values that mean delete. Default `["d", "delete"]`.
277    #[serde(default = "cdc_delete_ops")]
278    delete_ops: Vec<String>,
279    /// Op values that cause the record to be dropped (1→0). Default `["ddl", "truncate"]`.
280    #[serde(default = "cdc_drop_ops")]
281    drop_ops: Vec<String>,
282}
283
284#[cfg(feature = "transform-cdc-unwrap")]
285fn cdc_op_field() -> String {
286    "op".into()
287}
288#[cfg(feature = "transform-cdc-unwrap")]
289fn cdc_after_field() -> String {
290    "after".into()
291}
292#[cfg(feature = "transform-cdc-unwrap")]
293fn cdc_before_field() -> String {
294    "before".into()
295}
296#[cfg(feature = "transform-cdc-unwrap")]
297fn cdc_key_field() -> String {
298    "document_key".into()
299}
300#[cfg(feature = "transform-cdc-unwrap")]
301fn cdc_marker_field() -> String {
302    "__op".into()
303}
304#[cfg(feature = "transform-cdc-unwrap")]
305fn cdc_delete_ops() -> Vec<String> {
306    vec!["d".into(), "delete".into()]
307}
308#[cfg(feature = "transform-cdc-unwrap")]
309fn cdc_drop_ops() -> Vec<String> {
310    vec!["ddl".into(), "truncate".into()]
311}
312
313/// One row in the transform registry — the single source of truth for every
314/// built-in transform's kind, one-line description, JSON Schema, and
315/// `TransformSpec → TransformStage` decoder. `compile_one`,
316/// Match-key config for the `lookup` transform.
317#[cfg(feature = "transforms")]
318#[derive(Debug, Deserialize, JsonSchema)]
319struct LookupOnConfig {
320    /// Field on the incoming record to match on.
321    record: String,
322    /// Field on the reference rows to match against.
323    #[serde(rename = "ref")]
324    ref_field: String,
325}
326
327/// Inline-config schema for the `lookup` transform.
328#[cfg(feature = "transforms")]
329#[derive(Debug, Deserialize, JsonSchema)]
330struct LookupConfig {
331    /// Inline reference rows. Mutually exclusive with `jsonl`.
332    #[serde(default)]
333    values: Option<Vec<serde_json::Map<String, Value>>>,
334    /// Path to a JSONL file of reference rows (one JSON object per line).
335    /// Mutually exclusive with `values`.
336    #[serde(default)]
337    jsonl: Option<String>,
338    /// Match keys: `{ record: <record field>, ref: <reference field> }`.
339    on: LookupOnConfig,
340    /// Output columns to add, as `{ <output column>: <reference column> }`.
341    add: HashMap<String, String>,
342    /// Behaviour on a miss: `null` (default), `keep`, or `error`.
343    #[serde(default)]
344    on_missing: LookupOnMissing,
345}
346
347/// Resolve a `lookup` reference set from inline `values` or a `jsonl` file
348/// (exactly one must be set).
349#[cfg(feature = "transforms")]
350fn load_lookup_reference(
351    kind: &str,
352    cfg: &LookupConfig,
353) -> CliResult<Vec<serde_json::Map<String, Value>>> {
354    match (&cfg.values, &cfg.jsonl) {
355        (Some(_), Some(_)) => Err(CliError::InvalidTransform {
356            name: kind.to_owned(),
357            message: "set exactly one of `values` or `jsonl`, not both".to_owned(),
358        }),
359        (Some(rows), None) => Ok(rows.clone()),
360        (None, Some(path)) => {
361            let text = std::fs::read_to_string(path).map_err(|e| CliError::InvalidTransform {
362                name: kind.to_owned(),
363                message: format!("reading lookup jsonl '{path}': {e}"),
364            })?;
365            let mut rows = Vec::new();
366            for (i, line) in text.lines().enumerate() {
367                if line.trim().is_empty() {
368                    continue;
369                }
370                let row: serde_json::Map<String, Value> =
371                    serde_json::from_str(line).map_err(|e| CliError::InvalidTransform {
372                        name: kind.to_owned(),
373                        message: format!("lookup jsonl '{path}' line {}: {e}", i + 1),
374                    })?;
375                rows.push(row);
376            }
377            Ok(rows)
378        }
379        (None, None) => Err(CliError::InvalidTransform {
380            name: kind.to_owned(),
381            message: "a reference set is required: set `values` or `jsonl`".to_owned(),
382        }),
383    }
384}
385
386/// `transform_descriptions`, and `transform_schema` all read from this list
387/// so adding a new transform means appending one entry (no parallel match
388/// arms to keep in sync).
389struct TransformDef {
390    kind: &'static str,
391    description: &'static str,
392    schema_fn: fn() -> Value,
393    compile_fn: fn(&str, Value) -> CliResult<TransformStage>,
394}
395
396/// Every transform compiled into this build, in display order.
397///
398/// Non-capturing closures coerce to the `fn` pointers held by `TransformDef`,
399/// so each row stays a single self-contained record next to its sibling
400/// entries.
401fn registry() -> Vec<TransformDef> {
402    #[allow(unused_mut)]
403    let mut defs: Vec<TransformDef> = Vec::new();
404    #[cfg(feature = "transforms")]
405    {
406        defs.extend(vec![
407            TransformDef {
408                kind: "flatten",
409                description: "Flatten nested objects into a single level (configurable separator).",
410                schema_fn: || schema::<FlattenConfig>(),
411                compile_fn: |kind, config| {
412                    let cfg = decode::<FlattenConfig>(kind, config)?;
413                    Ok(TransformStage::Map(RecordTransform::Flatten {
414                        separator: cfg.separator,
415                    }))
416                },
417            },
418            TransformDef {
419                kind: "rename_keys",
420                description: "Rewrite every key via a regex pattern + replacement.",
421                schema_fn: || schema::<RenameKeysConfig>(),
422                compile_fn: |kind, config| {
423                    let cfg = decode::<RenameKeysConfig>(kind, config)?;
424                    Ok(TransformStage::Map(RecordTransform::RenameKeys {
425                        pattern: cfg.pattern,
426                        replacement: cfg.replacement,
427                    }))
428                },
429            },
430            TransformDef {
431                kind: "keys_case",
432                description: "Re-case every key (snake / camel / pascal / kebab / screaming_snake).",
433                schema_fn: || schema::<KeysCaseConfig>(),
434                compile_fn: |kind, config| {
435                    let cfg = decode::<KeysCaseConfig>(kind, config)?;
436                    Ok(TransformStage::Map(RecordTransform::KeysCase {
437                        mode: cfg.mode,
438                    }))
439                },
440            },
441            TransformDef {
442                kind: "select",
443                description: "Keep only the listed top-level fields; drop the rest.",
444                schema_fn: || schema::<FieldsConfig>(),
445                compile_fn: |kind, config| {
446                    let cfg = decode::<FieldsConfig>(kind, config)?;
447                    Ok(TransformStage::Map(RecordTransform::Select {
448                        fields: cfg.fields,
449                    }))
450                },
451            },
452            TransformDef {
453                kind: "drop",
454                description: "Remove the listed top-level fields.",
455                schema_fn: || schema::<FieldsConfig>(),
456                compile_fn: |kind, config| {
457                    let cfg = decode::<FieldsConfig>(kind, config)?;
458                    Ok(TransformStage::Map(RecordTransform::Drop {
459                        fields: cfg.fields,
460                    }))
461                },
462            },
463            TransformDef {
464                kind: "set",
465                description: "Set named fields to constant values on every record.",
466                schema_fn: || schema::<SetConfig>(),
467                compile_fn: |kind, config| {
468                    let cfg = decode::<SetConfig>(kind, config)?;
469                    Ok(TransformStage::Map(RecordTransform::Set {
470                        values: cfg.values,
471                    }))
472                },
473            },
474            TransformDef {
475                kind: "rename_field",
476                description: "Rename specific top-level fields by name.",
477                schema_fn: || schema::<RenameFieldConfig>(),
478                compile_fn: |kind, config| {
479                    let cfg = decode::<RenameFieldConfig>(kind, config)?;
480                    Ok(TransformStage::Map(RecordTransform::RenameField {
481                        fields: cfg.fields,
482                    }))
483                },
484            },
485            TransformDef {
486                kind: "cast",
487                description: "Coerce named fields to int / float / bool / string / timestamp.",
488                schema_fn: || schema::<CastConfig>(),
489                compile_fn: |kind, config| {
490                    let cfg = decode::<CastConfig>(kind, config)?;
491                    Ok(TransformStage::Map(RecordTransform::Cast {
492                        fields: cfg.fields,
493                        on_error: cfg.on_error,
494                    }))
495                },
496            },
497            TransformDef {
498                kind: "redact",
499                description: "Overwrite the listed fields with a mask value (default `***`).",
500                schema_fn: || schema::<RedactConfig>(),
501                compile_fn: |kind, config| {
502                    let cfg = decode::<RedactConfig>(kind, config)?;
503                    Ok(TransformStage::Map(RecordTransform::Redact {
504                        fields: cfg.fields,
505                        mask: cfg.mask,
506                    }))
507                },
508            },
509            TransformDef {
510                kind: "value_case",
511                description: "Lowercase, uppercase, or trim the value of named string fields.",
512                schema_fn: || schema::<ValueCaseConfig>(),
513                compile_fn: |kind, config| {
514                    let cfg = decode::<ValueCaseConfig>(kind, config)?;
515                    Ok(TransformStage::Map(RecordTransform::ValueCase {
516                        fields: cfg.fields,
517                        mode: cfg.mode,
518                    }))
519                },
520            },
521            TransformDef {
522                kind: "spell_symbols",
523                description: "Replace punctuation/symbols in string values with their spelled-out words.",
524                schema_fn: || schema::<SpellSymbolsConfig>(),
525                compile_fn: |kind, config| {
526                    let cfg = decode::<SpellSymbolsConfig>(kind, config)?;
527                    Ok(TransformStage::Map(RecordTransform::SpellSymbols {
528                        extra: cfg.extra,
529                        separator: cfg.separator,
530                    }))
531                },
532            },
533            TransformDef {
534                kind: "hash",
535                description: "Hash listed fields (SHA-256 / BLAKE3) into stable, join-able tokens.",
536                schema_fn: || schema::<HashConfig>(),
537                compile_fn: |kind, config| {
538                    let cfg = decode::<HashConfig>(kind, config)?;
539                    let stage = TransformStage::Map(RecordTransform::Hash {
540                        fields: cfg.fields,
541                        algorithm: cfg.algorithm,
542                        encoding: cfg.encoding,
543                        salt: cfg.salt,
544                        into: cfg.into,
545                    });
546                    validate_stage(kind, &stage)?;
547                    Ok(stage)
548                },
549            },
550            TransformDef {
551                kind: "json_parse",
552                description: "Parse a stringified-JSON field into a real nested JSON value.",
553                schema_fn: || schema::<JsonParseConfig>(),
554                compile_fn: |kind, config| {
555                    let cfg = decode::<JsonParseConfig>(kind, config)?;
556                    let stage = TransformStage::Map(RecordTransform::JsonParse {
557                        fields: cfg.fields,
558                        on_error: cfg.on_error,
559                        into: cfg.into,
560                    });
561                    validate_stage(kind, &stage)?;
562                    Ok(stage)
563                },
564            },
565            TransformDef {
566                kind: "coalesce",
567                description: "Fill a missing/null field from a default or first non-null fallback key.",
568                schema_fn: || schema::<CoalesceConfig>(),
569                compile_fn: |kind, config| {
570                    let cfg = decode::<CoalesceConfig>(kind, config)?;
571                    let stage = TransformStage::Map(RecordTransform::Coalesce {
572                        field: cfg.field,
573                        default: cfg.default,
574                        from: cfg.from,
575                        treat_empty_string_as_null: cfg.treat_empty_string_as_null,
576                    });
577                    validate_stage(kind, &stage)?;
578                    Ok(stage)
579                },
580            },
581            TransformDef {
582                kind: "split",
583                description: "Split a string field into an array on a delimiter.",
584                schema_fn: || schema::<SplitConfig>(),
585                compile_fn: |kind, config| {
586                    let cfg = decode::<SplitConfig>(kind, config)?;
587                    Ok(TransformStage::Map(RecordTransform::Split {
588                        field: cfg.field,
589                        delimiter: cfg.delimiter,
590                        trim: cfg.trim,
591                        into: cfg.into,
592                    }))
593                },
594            },
595            TransformDef {
596                kind: "join",
597                description: "Join an array field into a string with a delimiter.",
598                schema_fn: || schema::<JoinConfig>(),
599                compile_fn: |kind, config| {
600                    let cfg = decode::<JoinConfig>(kind, config)?;
601                    Ok(TransformStage::Map(RecordTransform::Join {
602                        field: cfg.field,
603                        delimiter: cfg.delimiter,
604                        into: cfg.into,
605                    }))
606                },
607            },
608            TransformDef {
609                kind: "json_encode",
610                description: "Serialize a nested field to a JSON string (inverse of json_parse).",
611                schema_fn: || schema::<FieldsConfig>(),
612                compile_fn: |kind, config| {
613                    let cfg = decode::<FieldsConfig>(kind, config)?;
614                    Ok(TransformStage::Map(RecordTransform::JsonEncode {
615                        fields: cfg.fields,
616                    }))
617                },
618            },
619            TransformDef {
620                kind: "unpivot",
621                description: "Reshape wide columns or a map field into long key/value rows.",
622                schema_fn: || schema::<UnpivotSpec>(),
623                compile_fn: |kind, config| {
624                    // `unpivot` is 1→N, so it compiles to an object-safe
625                    // `TransformStage::Custom` (no dedicated enum variant —
626                    // keeps `TransformStage` additive-only). `into_stage`
627                    // validates the spec at load time.
628                    let spec = decode::<UnpivotSpec>(kind, config)?;
629                    spec.into_stage().map_err(|e| CliError::InvalidTransform {
630                        name: kind.to_owned(),
631                        message: e.to_string(),
632                    })
633                },
634            },
635            TransformDef {
636                kind: "tree_flatten",
637                description: "Flatten a recursive report tree / matrix (nested Rows) into one row per leaf.",
638                schema_fn: || schema::<TreeFlattenSpec>(),
639                compile_fn: |kind, config| {
640                    // `tree_flatten` is 1→N, so it compiles to an object-safe
641                    // `TransformStage::Custom` (no dedicated enum variant).
642                    // `into_stage` validates the spec at load time.
643                    let spec = decode::<TreeFlattenSpec>(kind, config)?;
644                    spec.into_stage().map_err(|e| CliError::InvalidTransform {
645                        name: kind.to_owned(),
646                        message: e.to_string(),
647                    })
648                },
649            },
650            TransformDef {
651                kind: "cross_join",
652                description: "Expand a record into the cartesian product of two or more of its sibling array fields.",
653                schema_fn: || schema::<CrossJoinSpec>(),
654                compile_fn: |kind, config| {
655                    // `cross_join` is 1→N and fails loudly on product overflow,
656                    // so it compiles to a fallible `TransformStage::PageFn`.
657                    // `into_stage` validates the spec at load time.
658                    let spec = decode::<CrossJoinSpec>(kind, config)?;
659                    spec.into_stage().map_err(|e| CliError::InvalidTransform {
660                        name: kind.to_owned(),
661                        message: e.to_string(),
662                    })
663                },
664            },
665            #[cfg(feature = "transform-zip-columns")]
666            TransformDef {
667                kind: "zip_columns",
668                description: "Zip a columnar payload ({columns, rows}) into one object per row.",
669                schema_fn: || schema::<ZipColumnsSpec>(),
670                compile_fn: |kind, config| {
671                    // `zip_columns` is 1→N and fails loudly on a row/column
672                    // width mismatch, so it compiles to a fallible
673                    // `TransformStage::PageFn`. `into_stage` validates the spec.
674                    let spec = decode::<ZipColumnsSpec>(kind, config)?;
675                    spec.into_stage().map_err(|e| CliError::InvalidTransform {
676                        name: kind.to_owned(),
677                        message: e.to_string(),
678                    })
679                },
680            },
681            TransformDef {
682                kind: "lookup",
683                description: "Enrich records by joining against an inline/JSONL reference table.",
684                schema_fn: || schema::<LookupConfig>(),
685                compile_fn: |kind, config| {
686                    let cfg = decode::<LookupConfig>(kind, config)?;
687                    let reference = load_lookup_reference(kind, &cfg)?;
688                    let add: Vec<(String, String)> = cfg.add.into_iter().collect();
689                    let stage = TransformStage::Map(RecordTransform::Lookup {
690                        reference,
691                        on_record: cfg.on.record,
692                        on_ref: cfg.on.ref_field,
693                        add,
694                        on_missing: cfg.on_missing,
695                    });
696                    validate_stage(kind, &stage)?;
697                    Ok(stage)
698                },
699            },
700            #[cfg(feature = "transform-filter")]
701            TransformDef {
702                kind: "filter",
703                description: "Keep records where a JSONPath predicate is true.",
704                schema_fn: || schema::<FilterConfig>(),
705                compile_fn: |kind, config| {
706                    let cfg = decode::<FilterConfig>(kind, config)?;
707                    // Re-use stage's compile-time validation so error messages match.
708                    let stage = TransformStage::Filter(faucet_core::FilterSpec {
709                        path: cfg.path,
710                        op: cfg.op,
711                        value: cfg.value,
712                    });
713                    faucet_core::compile_stage(&stage).map_err(|e| match e {
714                        faucet_core::FaucetError::Transform(msg) => CliError::InvalidTransform {
715                            name: kind.to_owned(),
716                            message: msg,
717                        },
718                        other => CliError::InvalidTransform {
719                            name: kind.to_owned(),
720                            message: format!("{other}"),
721                        },
722                    })?;
723                    Ok(stage)
724                },
725            },
726            #[cfg(feature = "transform-explode")]
727            TransformDef {
728                kind: "explode",
729                description: "Expand an array field into one record per element.",
730                schema_fn: || schema::<ExplodeConfig>(),
731                compile_fn: |kind, config| {
732                    let cfg = decode::<ExplodeConfig>(kind, config)?;
733                    let carry = cfg.carry.clone();
734                    let stage = TransformStage::Explode(faucet_core::ExplodeSpec {
735                        path: cfg.path,
736                        prefix: cfg.prefix,
737                        separator: cfg.separator,
738                        on_missing: cfg.on_missing,
739                    });
740                    let compiled =
741                        faucet_core::compile_stage(&stage).map_err(|e| match e {
742                            faucet_core::FaucetError::Transform(msg) => {
743                                CliError::InvalidTransform {
744                                    name: kind.to_owned(),
745                                    message: msg,
746                                }
747                            }
748                            other => CliError::InvalidTransform {
749                                name: kind.to_owned(),
750                                message: format!("{other}"),
751                            },
752                        })?;
753                    if carry.is_empty() {
754                        return Ok(stage);
755                    }
756                    // #555: carry named parent fields onto every child. Reuse the
757                    // core explode (`apply_stages`) then inject the carried
758                    // values, resolved from the ORIGINAL parent record. A
759                    // page-level fallible stage so an explode error still
760                    // propagates.
761                    let carry: Vec<(String, String)> = carry.into_iter().collect();
762                    Ok(TransformStage::PageFn(std::sync::Arc::new(
763                        move |page: Vec<Value>| {
764                            let mut out = Vec::with_capacity(page.len());
765                            for rec in page {
766                                let carried: Vec<(String, Value)> = carry
767                                    .iter()
768                                    .filter_map(|(dest, src)| {
769                                        get_dot_path(&rec, src).map(|v| (dest.clone(), v))
770                                    })
771                                    .collect();
772                                let children = faucet_core::apply_stages(
773                                    rec,
774                                    std::slice::from_ref(&compiled),
775                                )?;
776                                for mut child in children {
777                                    if let Value::Object(map) = &mut child {
778                                        for (dest, val) in &carried {
779                                            map.insert(dest.clone(), val.clone());
780                                        }
781                                    }
782                                    out.push(child);
783                                }
784                            }
785                            Ok(out)
786                        },
787                    )))
788                },
789            },
790        ]);
791    }
792    #[cfg(feature = "transform-sql")]
793    {
794        defs.push(TransformDef {
795            kind: "sql",
796            description: "Run DuckDB SQL over the whole page; records are the `batch` relation.",
797            schema_fn: || schema_sql(),
798            compile_fn: |kind, config| {
799                let cfg: faucet_transform_sql::SqlTransformConfig = decode_sql(kind, config)?;
800                let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
801                    let message = match &e {
802                        faucet_core::FaucetError::Transform(m)
803                        | faucet_core::FaucetError::Config(m) => m.clone(),
804                        other => format!("{other}"),
805                    };
806                    CliError::InvalidTransform {
807                        name: kind.to_owned(),
808                        message,
809                    }
810                })?;
811                Ok(transform.into_page_stage())
812            },
813        });
814    }
815    #[cfg(feature = "transform-wasm")]
816    {
817        defs.push(TransformDef {
818            kind: "wasm",
819            description: "Run a user-provided sandboxed .wasm module over each record (wasmtime).",
820            schema_fn: || schema_wasm(),
821            compile_fn: |kind, config| {
822                let cfg: faucet_transform_wasm::WasmTransformConfig = decode_wasm(kind, config)?;
823                let transform =
824                    faucet_transform_wasm::WasmTransform::compile(&cfg).map_err(|e| {
825                        let message = match &e {
826                            faucet_core::FaucetError::Transform(m)
827                            | faucet_core::FaucetError::Config(m) => m.clone(),
828                            other => format!("{other}"),
829                        };
830                        CliError::InvalidTransform {
831                            name: kind.to_owned(),
832                            message,
833                        }
834                    })?;
835                Ok(transform.into_page_stage())
836            },
837        });
838    }
839    #[cfg(feature = "transform-cdc-unwrap")]
840    {
841        defs.push(TransformDef {
842            kind: "cdc_unwrap",
843            description: "Normalize a CDC envelope into a flat row + delete marker (for upsert sinks).",
844            schema_fn: || schema::<CdcUnwrapConfig>(),
845            compile_fn: |kind, config| {
846                let cfg = decode::<CdcUnwrapConfig>(kind, config)?;
847                Ok(faucet_core::TransformStage::CdcUnwrap(faucet_core::CdcUnwrapSpec {
848                    op_field: cfg.op_field,
849                    after_field: cfg.after_field,
850                    before_field: cfg.before_field,
851                    key_field: cfg.key_field,
852                    marker_field: cfg.marker_field,
853                    delete_ops: cfg.delete_ops,
854                    drop_ops: cfg.drop_ops,
855                }))
856            },
857        });
858    }
859    defs
860}
861
862/// Compile a list of [`TransformSpec`]s into [`TransformStage`]s in the
863/// declared order. Most built-ins compile to a [`TransformStage::Map`];
864/// richer stages (e.g. `filter`, future fan-outs) compile to other
865/// variants. Unknown or malformed entries surface as a `CliError`.
866pub fn compile_transforms(specs: &[TransformSpec]) -> CliResult<Vec<TransformStage>> {
867    let mut out = Vec::with_capacity(specs.len());
868    for s in specs {
869        out.push(compile_one(s)?);
870    }
871    Ok(out)
872}
873
874fn compile_one(spec: &TransformSpec) -> CliResult<TransformStage> {
875    match registry().into_iter().find(|t| t.kind == spec.kind) {
876        Some(def) => (def.compile_fn)(&spec.kind, spec.config.clone()),
877        None => Err(unknown_transform(&spec.kind)),
878    }
879}
880
881/// Like [`compile_transforms`] but also returns each stage's Arrow
882/// `RecordBatch` form (parallel to the stages), so the executor can build a
883/// columnar-capable `TransformingSource` (#375). Only the `sql` transform
884/// supplies a batch form today; every other stage's entry is `None`, which
885/// keeps the whole chain on the `Value` path unless every stage is columnar.
886#[cfg(feature = "arrow")]
887pub fn compile_transforms_columnar(
888    specs: &[TransformSpec],
889) -> CliResult<(
890    Vec<TransformStage>,
891    Vec<Option<faucet_core::stage::PageFnBatchBox>>,
892)> {
893    let mut stages = Vec::with_capacity(specs.len());
894    let mut batches = Vec::with_capacity(specs.len());
895    for s in specs {
896        #[cfg(feature = "transform-sql")]
897        if s.kind == "sql" {
898            let cfg = decode_sql("sql", s.config.clone())?;
899            let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
900                let message = match &e {
901                    faucet_core::FaucetError::Transform(m)
902                    | faucet_core::FaucetError::Config(m) => m.clone(),
903                    other => format!("{other}"),
904                };
905                CliError::InvalidTransform {
906                    name: "sql".to_owned(),
907                    message,
908                }
909            })?;
910            let (stage, batch) = transform.into_columnar_stage();
911            stages.push(stage);
912            batches.push(Some(batch));
913            continue;
914        }
915        stages.push(compile_one(s)?);
916        batches.push(None);
917    }
918    Ok((stages, batches))
919}
920
921/// One-line summary of every transform compiled into this build. Used by
922/// `faucet list`.
923pub fn transform_descriptions() -> Vec<(&'static str, &'static str)> {
924    registry()
925        .into_iter()
926        .map(|t| (t.kind, t.description))
927        .collect()
928}
929
930/// Names of every transform compiled into this build.
931pub fn available_transforms() -> Vec<&'static str> {
932    registry().into_iter().map(|t| t.kind).collect()
933}
934
935// Keep in sync with faucet_core::{RecordCheck, BatchCheck} — one entry per check variant.
936/// One-line descriptions of the available quality checks, for `faucet list`.
937/// The `json_schema` entry only appears when the `quality-jsonschema` feature
938/// is enabled, mirroring `faucet schema quality` so `list` and `schema` agree.
939#[cfg(feature = "quality")]
940pub fn quality_descriptions() -> Vec<(&'static str, &'static str)> {
941    let mut checks = vec![
942        ("not_null", "field present and non-null"),
943        ("not_empty", "string non-empty after trim"),
944        ("regex_match", "string matches a regex"),
945        ("value_in_set", "value is in an allowed set"),
946        ("not_in_set", "value is not in a forbidden set"),
947        ("compare", "numeric/scalar comparison (gt/gte/lt/lte/eq/ne)"),
948        ("type_is", "value is of an expected JSON type"),
949        ("string_length", "string length within [min,max]"),
950    ];
951    #[cfg(feature = "quality-jsonschema")]
952    checks.push((
953        "json_schema",
954        "record validates against a JSON Schema (feature-gated)",
955    ));
956    checks.extend([
957        ("row_count", "batch row count within [min,max]"),
958        ("null_rate", "batch null rate of a field <= max"),
959        ("unique", "composite key unique within the batch"),
960        (
961            "distinct_count",
962            "distinct values of a field within [min,max]",
963        ),
964    ]);
965    checks
966}
967
968/// Return the JSON Schema for the named transform's config. Mirrors
969/// `registry::source_schema` / `sink_schema` so `faucet schema transform <name>`
970/// reads symmetrically with the connector variants.
971pub fn transform_schema(name: &str) -> CliResult<Value> {
972    registry()
973        .into_iter()
974        .find(|t| t.kind == name)
975        .map(|t| (t.schema_fn)())
976        .ok_or_else(|| unknown_transform(name))
977}
978
979fn unknown_transform(name: &str) -> CliError {
980    let available = available_transforms();
981    CliError::UnknownTransform {
982        name: name.to_owned(),
983        available: if available.is_empty() {
984            "(none — rebuild faucet-cli with the `transforms` feature enabled)".to_owned()
985        } else {
986            available.join(", ")
987        },
988    }
989}
990
991#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
992fn schema<T: JsonSchema>() -> Value {
993    serde_json::to_value(schema_for!(T)).unwrap_or_else(|_| serde_json::json!({"type": "object"}))
994}
995
996#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
997fn decode<T: serde::de::DeserializeOwned>(name: &str, config: Value) -> CliResult<T> {
998    serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
999        name: name.to_owned(),
1000        message: e.to_string(),
1001    })
1002}
1003
1004/// Compile-validate a `Map` stage so its config errors (empty `fields`, `into`
1005/// with multiple fields, `coalesce` requiring exactly one source) surface at
1006/// load time as `CliError::InvalidTransform` — mirroring how `filter` /
1007/// `explode` validate their specs at compile time.
1008#[cfg(feature = "transforms")]
1009fn validate_stage(kind: &str, stage: &TransformStage) -> CliResult<()> {
1010    faucet_core::compile_stage(stage).map(|_| ()).map_err(|e| {
1011        let message = match e {
1012            faucet_core::FaucetError::Transform(m) | faucet_core::FaucetError::Config(m) => m,
1013            other => format!("{other}"),
1014        };
1015        CliError::InvalidTransform {
1016            name: kind.to_owned(),
1017            message,
1018        }
1019    })
1020}
1021
1022#[cfg(feature = "transform-sql")]
1023fn schema_sql() -> Value {
1024    serde_json::to_value(faucet_core::schema_for!(
1025        faucet_transform_sql::SqlTransformConfig
1026    ))
1027    .unwrap_or(Value::Null)
1028}
1029
1030#[cfg(feature = "transform-sql")]
1031fn decode_sql(kind: &str, config: Value) -> CliResult<faucet_transform_sql::SqlTransformConfig> {
1032    serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
1033        name: kind.to_owned(),
1034        message: e.to_string(),
1035    })
1036}
1037
1038#[cfg(feature = "transform-wasm")]
1039fn schema_wasm() -> Value {
1040    serde_json::to_value(faucet_core::schema_for!(
1041        faucet_transform_wasm::WasmTransformConfig
1042    ))
1043    .unwrap_or(Value::Null)
1044}
1045
1046#[cfg(feature = "transform-wasm")]
1047fn decode_wasm(kind: &str, config: Value) -> CliResult<faucet_transform_wasm::WasmTransformConfig> {
1048    serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
1049        name: kind.to_owned(),
1050        message: e.to_string(),
1051    })
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057    use serde_json::json;
1058
1059    #[test]
1060    fn empty_list_compiles_to_empty() {
1061        let out = compile_transforms(&[]).unwrap();
1062        assert!(out.is_empty());
1063    }
1064
1065    #[cfg(feature = "transforms")]
1066    #[test]
1067    fn compiles_keys_case_and_flatten() {
1068        let specs = vec![
1069            TransformSpec {
1070                kind: "keys_case".into(),
1071                config: json!({"mode": "snake"}),
1072            },
1073            TransformSpec {
1074                kind: "flatten".into(),
1075                config: json!({"separator": "."}),
1076            },
1077        ];
1078        let out = compile_transforms(&specs).unwrap();
1079        assert_eq!(out.len(), 2);
1080    }
1081
1082    #[cfg(feature = "transforms")]
1083    #[test]
1084    fn keys_case_rejects_unknown_mode() {
1085        let specs = vec![TransformSpec {
1086            kind: "keys_case".into(),
1087            config: json!({"mode": "spongebob"}),
1088        }];
1089        let err = compile_transforms(&specs).unwrap_err();
1090        match err {
1091            CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
1092            other => panic!("expected InvalidTransform, got {other:?}"),
1093        }
1094    }
1095
1096    #[cfg(feature = "transforms")]
1097    #[test]
1098    fn keys_case_requires_mode() {
1099        let specs = vec![TransformSpec {
1100            kind: "keys_case".into(),
1101            config: json!({}),
1102        }];
1103        let err = compile_transforms(&specs).unwrap_err();
1104        match err {
1105            CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
1106            other => panic!("expected InvalidTransform, got {other:?}"),
1107        }
1108    }
1109
1110    #[cfg(feature = "transforms")]
1111    #[test]
1112    fn snake_case_kind_is_no_longer_recognized() {
1113        // Removed in favour of `keys_case { mode: snake }`.
1114        let specs = vec![TransformSpec {
1115            kind: "snake_case".into(),
1116            config: json!({}),
1117        }];
1118        let err = compile_transforms(&specs).unwrap_err();
1119        match err {
1120            CliError::UnknownTransform { name, .. } => assert_eq!(name, "snake_case"),
1121            other => panic!("expected UnknownTransform, got {other:?}"),
1122        }
1123    }
1124
1125    #[cfg(feature = "transforms")]
1126    #[test]
1127    fn rename_keys_requires_pattern_and_replacement() {
1128        let specs = vec![TransformSpec {
1129            kind: "rename_keys".into(),
1130            config: json!({"pattern": "^_"}),
1131        }];
1132        let err = compile_transforms(&specs).unwrap_err();
1133        match err {
1134            CliError::InvalidTransform { name, .. } => assert_eq!(name, "rename_keys"),
1135            other => panic!("expected InvalidTransform, got {other:?}"),
1136        }
1137    }
1138
1139    #[test]
1140    fn unknown_transform_errors() {
1141        let specs = vec![TransformSpec {
1142            kind: "make_uppercase".into(),
1143            config: json!({}),
1144        }];
1145        let err = compile_transforms(&specs).unwrap_err();
1146        match err {
1147            CliError::UnknownTransform { name, .. } => assert_eq!(name, "make_uppercase"),
1148            other => panic!("expected UnknownTransform, got {other:?}"),
1149        }
1150    }
1151
1152    #[cfg(feature = "transforms")]
1153    #[test]
1154    fn compiles_select_and_drop() {
1155        let specs = vec![
1156            TransformSpec {
1157                kind: "select".into(),
1158                config: json!({"fields": ["id", "name"]}),
1159            },
1160            TransformSpec {
1161                kind: "drop".into(),
1162                config: json!({"fields": ["secret"]}),
1163            },
1164        ];
1165        let out = compile_transforms(&specs).unwrap();
1166        assert_eq!(out.len(), 2);
1167    }
1168
1169    #[cfg(feature = "transforms")]
1170    #[test]
1171    fn compiles_set_with_object_values() {
1172        let specs = vec![TransformSpec {
1173            kind: "set".into(),
1174            config: json!({"values": {"_source": "api", "version": 1}}),
1175        }];
1176        let out = compile_transforms(&specs).unwrap();
1177        assert_eq!(out.len(), 1);
1178    }
1179
1180    #[cfg(feature = "transforms")]
1181    #[test]
1182    fn compiles_rename_field() {
1183        let specs = vec![TransformSpec {
1184            kind: "rename_field".into(),
1185            config: json!({"fields": {"old": "new"}}),
1186        }];
1187        let out = compile_transforms(&specs).unwrap();
1188        assert_eq!(out.len(), 1);
1189    }
1190
1191    #[cfg(feature = "transforms")]
1192    #[test]
1193    fn compiles_cast_with_default_on_error() {
1194        let specs = vec![TransformSpec {
1195            kind: "cast".into(),
1196            config: json!({"fields": {"age": "int", "price": "float"}}),
1197        }];
1198        let out = compile_transforms(&specs).unwrap();
1199        assert_eq!(out.len(), 1);
1200    }
1201
1202    #[cfg(feature = "transforms")]
1203    #[test]
1204    fn cast_rejects_unknown_target_type() {
1205        let specs = vec![TransformSpec {
1206            kind: "cast".into(),
1207            config: json!({"fields": {"x": "uuid"}}),
1208        }];
1209        let err = compile_transforms(&specs).unwrap_err();
1210        match err {
1211            CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
1212            other => panic!("expected InvalidTransform, got {other:?}"),
1213        }
1214    }
1215
1216    #[cfg(feature = "transforms")]
1217    #[test]
1218    fn cast_rejects_unknown_on_error_mode() {
1219        let specs = vec![TransformSpec {
1220            kind: "cast".into(),
1221            config: json!({"fields": {"x": "int"}, "on_error": "explode"}),
1222        }];
1223        let err = compile_transforms(&specs).unwrap_err();
1224        match err {
1225            CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
1226            other => panic!("expected InvalidTransform, got {other:?}"),
1227        }
1228    }
1229
1230    #[cfg(feature = "transforms")]
1231    #[test]
1232    fn redact_uses_default_mask_when_omitted() {
1233        let specs = vec![TransformSpec {
1234            kind: "redact".into(),
1235            config: json!({"fields": ["ssn"]}),
1236        }];
1237        let out = compile_transforms(&specs).unwrap();
1238        assert_eq!(out.len(), 1);
1239    }
1240
1241    #[cfg(feature = "transforms")]
1242    #[test]
1243    fn compiles_hash_with_defaults_and_options() {
1244        let specs = vec![
1245            TransformSpec {
1246                kind: "hash".into(),
1247                config: json!({"fields": ["email"]}),
1248            },
1249            TransformSpec {
1250                kind: "hash".into(),
1251                config: json!({
1252                    "fields": ["user_id"],
1253                    "algorithm": "blake3",
1254                    "encoding": "base64",
1255                    "salt": "pepper",
1256                    "into": "user_id_hash"
1257                }),
1258            },
1259        ];
1260        let out = compile_transforms(&specs).unwrap();
1261        assert_eq!(out.len(), 2);
1262    }
1263
1264    #[cfg(feature = "transforms")]
1265    #[test]
1266    fn compiles_reshape_transforms() {
1267        let specs = vec![
1268            TransformSpec {
1269                kind: "json_encode".into(),
1270                config: json!({"fields": ["addr", "line_items"]}),
1271            },
1272            TransformSpec {
1273                kind: "unpivot".into(),
1274                config: json!({"id_fields": ["id"], "key_name": "month", "value_name": "amount"}),
1275            },
1276            TransformSpec {
1277                kind: "lookup".into(),
1278                config: json!({
1279                    "values": [{"id": 1, "name": "Alice"}],
1280                    "on": {"record": "user_id", "ref": "id"},
1281                    "add": {"user_name": "name"}
1282                }),
1283            },
1284        ];
1285        let out = compile_transforms(&specs).unwrap();
1286        assert_eq!(out.len(), 3);
1287    }
1288
1289    #[cfg(feature = "transforms")]
1290    #[test]
1291    fn compiles_cross_join() {
1292        let specs = vec![TransformSpec {
1293            kind: "cross_join".into(),
1294            config: json!({"arrays": ["jobs", "compensation"], "prefix": true}),
1295        }];
1296        let out = compile_transforms(&specs).unwrap();
1297        assert_eq!(out.len(), 1);
1298        assert!(matches!(out[0], TransformStage::PageFn(_)));
1299    }
1300
1301    #[cfg(feature = "transforms")]
1302    #[test]
1303    fn cross_join_single_array_rejected_at_load() {
1304        let specs = vec![TransformSpec {
1305            kind: "cross_join".into(),
1306            config: json!({"arrays": ["only"]}),
1307        }];
1308        assert!(compile_transforms(&specs).is_err());
1309    }
1310
1311    #[cfg(feature = "transform-zip-columns")]
1312    #[test]
1313    fn zip_columns_compiles_and_zips() {
1314        let specs = vec![TransformSpec {
1315            kind: "zip_columns".into(),
1316            config: json!({"columns_path": "columns[*].name", "rows_path": "rows"}),
1317        }];
1318        let out = compile_transforms(&specs).unwrap();
1319        let TransformStage::PageFn(f) = &out[0] else {
1320            panic!("expected PageFn");
1321        };
1322        let page = vec![json!({
1323            "columns": [{"name": "day"}, {"name": "n"}],
1324            "rows": [["2026-01-01", 3], ["2026-01-02", 5]],
1325        })];
1326        let got = f(page).unwrap();
1327        assert_eq!(
1328            got,
1329            vec![
1330                json!({"day": "2026-01-01", "n": 3}),
1331                json!({"day": "2026-01-02", "n": 5}),
1332            ]
1333        );
1334    }
1335
1336    #[cfg(feature = "transform-explode")]
1337    #[test]
1338    fn explode_carry_copies_parent_field_onto_children() {
1339        let specs = vec![TransformSpec {
1340            kind: "explode".into(),
1341            config: json!({
1342                "path": "values",
1343                "prefix": "",
1344                "carry": {"employee_id": "id"},
1345            }),
1346        }];
1347        let out = compile_transforms(&specs).unwrap();
1348        // With `carry`, explode compiles to a page-level stage.
1349        let TransformStage::PageFn(f) = &out[0] else {
1350            panic!("expected PageFn for explode+carry");
1351        };
1352        let page = vec![json!({"id": 7, "values": [{"v": "a"}, {"v": "b"}]})];
1353        let got = f(page).unwrap();
1354        assert_eq!(got.len(), 2);
1355        // Each child keeps the exploded element AND the carried parent id.
1356        assert_eq!(got[0]["v"], json!("a"));
1357        assert_eq!(got[0]["employee_id"], json!(7));
1358        assert_eq!(got[1]["v"], json!("b"));
1359        assert_eq!(got[1]["employee_id"], json!(7));
1360    }
1361
1362    #[cfg(feature = "transform-explode")]
1363    #[test]
1364    fn explode_without_carry_stays_a_plain_explode_stage() {
1365        let specs = vec![TransformSpec {
1366            kind: "explode".into(),
1367            config: json!({"path": "values"}),
1368        }];
1369        let out = compile_transforms(&specs).unwrap();
1370        assert!(matches!(out[0], TransformStage::Explode(_)));
1371    }
1372
1373    #[cfg(feature = "transforms")]
1374    #[test]
1375    fn unpivot_empty_key_name_rejected_at_load() {
1376        let specs = vec![TransformSpec {
1377            kind: "unpivot".into(),
1378            config: json!({"key_name": "", "value_name": "v"}),
1379        }];
1380        let err = compile_transforms(&specs).unwrap_err();
1381        assert!(
1382            matches!(&err, CliError::InvalidTransform { name, .. } if name == "unpivot"),
1383            "got {err:?}"
1384        );
1385    }
1386
1387    #[cfg(feature = "transforms")]
1388    #[test]
1389    fn lookup_requires_a_reference_and_add() {
1390        // no `values`/`jsonl`
1391        let no_ref = vec![TransformSpec {
1392            kind: "lookup".into(),
1393            config: json!({"on": {"record": "k", "ref": "k"}, "add": {"x": "y"}}),
1394        }];
1395        assert!(matches!(
1396            compile_transforms(&no_ref).unwrap_err(),
1397            CliError::InvalidTransform { .. }
1398        ));
1399        // empty `add`
1400        let no_add = vec![TransformSpec {
1401            kind: "lookup".into(),
1402            config: json!({"values": [], "on": {"record": "k", "ref": "k"}, "add": {}}),
1403        }];
1404        assert!(matches!(
1405            compile_transforms(&no_add).unwrap_err(),
1406            CliError::InvalidTransform { .. }
1407        ));
1408    }
1409
1410    #[cfg(feature = "transforms")]
1411    #[test]
1412    fn lookup_reads_a_jsonl_reference_file() {
1413        let dir = tempfile::tempdir().unwrap();
1414        let path = dir.path().join("ref.jsonl");
1415        std::fs::write(
1416            &path,
1417            "{\"id\":\"1\",\"name\":\"Alice\"}\n\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
1418        )
1419        .unwrap();
1420        let specs = vec![TransformSpec {
1421            kind: "lookup".into(),
1422            config: json!({
1423                "jsonl": path.to_str().unwrap(),
1424                "on": {"record": "uid", "ref": "id"},
1425                "add": {"uname": "name"}
1426            }),
1427        }];
1428        assert_eq!(compile_transforms(&specs).unwrap().len(), 1);
1429    }
1430
1431    #[cfg(feature = "transforms")]
1432    #[test]
1433    fn lookup_rejects_both_values_and_jsonl() {
1434        let specs = vec![TransformSpec {
1435            kind: "lookup".into(),
1436            config: json!({
1437                "values": [{"id": "1"}],
1438                "jsonl": "ref.jsonl",
1439                "on": {"record": "uid", "ref": "id"},
1440                "add": {"uname": "name"}
1441            }),
1442        }];
1443        let err = compile_transforms(&specs).unwrap_err();
1444        assert!(
1445            matches!(&err, CliError::InvalidTransform { name, message }
1446                if name == "lookup" && message.contains("exactly one")),
1447            "got {err:?}"
1448        );
1449    }
1450
1451    #[cfg(feature = "transforms")]
1452    #[test]
1453    fn lookup_jsonl_missing_file_is_rejected_at_load() {
1454        let specs = vec![TransformSpec {
1455            kind: "lookup".into(),
1456            config: json!({
1457                "jsonl": "/nonexistent/faucet-lookup-ref.jsonl",
1458                "on": {"record": "uid", "ref": "id"},
1459                "add": {"uname": "name"}
1460            }),
1461        }];
1462        let err = compile_transforms(&specs).unwrap_err();
1463        assert!(
1464            matches!(&err, CliError::InvalidTransform { name, message }
1465                if name == "lookup" && message.contains("reading lookup jsonl")),
1466            "got {err:?}"
1467        );
1468    }
1469
1470    #[cfg(feature = "transforms")]
1471    #[test]
1472    fn lookup_jsonl_malformed_line_is_rejected_at_load() {
1473        let dir = tempfile::tempdir().unwrap();
1474        let path = dir.path().join("bad.jsonl");
1475        std::fs::write(&path, "{\"id\":\"1\"}\nnot json\n").unwrap();
1476        let specs = vec![TransformSpec {
1477            kind: "lookup".into(),
1478            config: json!({
1479                "jsonl": path.to_str().unwrap(),
1480                "on": {"record": "uid", "ref": "id"},
1481                "add": {"uname": "name"}
1482            }),
1483        }];
1484        let err = compile_transforms(&specs).unwrap_err();
1485        assert!(
1486            matches!(&err, CliError::InvalidTransform { name, message }
1487                if name == "lookup" && message.contains("line 2")),
1488            "got {err:?}"
1489        );
1490    }
1491
1492    #[cfg(feature = "transforms")]
1493    #[test]
1494    fn reshape_transforms_have_schema_and_descriptions() {
1495        for k in ["json_encode", "unpivot", "lookup"] {
1496            assert!(transform_schema(k).is_ok(), "schema for {k}");
1497            assert!(
1498                transform_descriptions().iter().any(|(n, _)| *n == k),
1499                "description for {k}"
1500            );
1501        }
1502    }
1503
1504    #[cfg(feature = "transforms")]
1505    #[test]
1506    fn hash_empty_fields_is_rejected_at_load() {
1507        let specs = vec![TransformSpec {
1508            kind: "hash".into(),
1509            config: json!({"fields": []}),
1510        }];
1511        match compile_transforms(&specs).unwrap_err() {
1512            CliError::InvalidTransform { name, .. } => assert_eq!(name, "hash"),
1513            other => panic!("expected InvalidTransform, got {other:?}"),
1514        }
1515    }
1516
1517    #[cfg(feature = "transforms")]
1518    #[test]
1519    fn hash_into_with_multiple_fields_is_rejected_at_load() {
1520        let specs = vec![TransformSpec {
1521            kind: "hash".into(),
1522            config: json!({"fields": ["a", "b"], "into": "x"}),
1523        }];
1524        match compile_transforms(&specs).unwrap_err() {
1525            CliError::InvalidTransform { name, message } => {
1526                assert_eq!(name, "hash");
1527                assert!(message.contains("into"), "{message}");
1528            }
1529            other => panic!("expected InvalidTransform, got {other:?}"),
1530        }
1531    }
1532
1533    #[cfg(feature = "transforms")]
1534    #[test]
1535    fn compiles_json_parse_with_on_error() {
1536        let specs = vec![TransformSpec {
1537            kind: "json_parse".into(),
1538            config: json!({"fields": ["payload"], "on_error": "null", "into": "parsed"}),
1539        }];
1540        let out = compile_transforms(&specs).unwrap();
1541        assert_eq!(out.len(), 1);
1542    }
1543
1544    #[cfg(feature = "transforms")]
1545    #[test]
1546    fn json_parse_into_with_multiple_fields_is_rejected_at_load() {
1547        let specs = vec![TransformSpec {
1548            kind: "json_parse".into(),
1549            config: json!({"fields": ["a", "b"], "into": "x"}),
1550        }];
1551        match compile_transforms(&specs).unwrap_err() {
1552            CliError::InvalidTransform { name, .. } => assert_eq!(name, "json_parse"),
1553            other => panic!("expected InvalidTransform, got {other:?}"),
1554        }
1555    }
1556
1557    #[cfg(feature = "transforms")]
1558    #[test]
1559    fn compiles_coalesce_with_default() {
1560        let specs = vec![TransformSpec {
1561            kind: "coalesce".into(),
1562            config: json!({"field": "status", "default": "unknown"}),
1563        }];
1564        let out = compile_transforms(&specs).unwrap();
1565        assert_eq!(out.len(), 1);
1566    }
1567
1568    #[cfg(feature = "transforms")]
1569    #[test]
1570    fn compiles_coalesce_with_from() {
1571        let specs = vec![TransformSpec {
1572            kind: "coalesce".into(),
1573            config: json!({"field": "status", "from": ["status", "state"]}),
1574        }];
1575        let out = compile_transforms(&specs).unwrap();
1576        assert_eq!(out.len(), 1);
1577    }
1578
1579    #[cfg(feature = "transforms")]
1580    #[test]
1581    fn coalesce_both_default_and_from_is_rejected_at_load() {
1582        let specs = vec![TransformSpec {
1583            kind: "coalesce".into(),
1584            config: json!({"field": "s", "default": "x", "from": ["y"]}),
1585        }];
1586        match compile_transforms(&specs).unwrap_err() {
1587            CliError::InvalidTransform { name, .. } => assert_eq!(name, "coalesce"),
1588            other => panic!("expected InvalidTransform, got {other:?}"),
1589        }
1590    }
1591
1592    #[cfg(feature = "transforms")]
1593    #[test]
1594    fn coalesce_neither_default_nor_from_is_rejected_at_load() {
1595        let specs = vec![TransformSpec {
1596            kind: "coalesce".into(),
1597            config: json!({"field": "s"}),
1598        }];
1599        match compile_transforms(&specs).unwrap_err() {
1600            CliError::InvalidTransform { name, .. } => assert_eq!(name, "coalesce"),
1601            other => panic!("expected InvalidTransform, got {other:?}"),
1602        }
1603    }
1604
1605    #[cfg(feature = "transforms")]
1606    #[test]
1607    fn compiles_split_and_join() {
1608        let specs = vec![
1609            TransformSpec {
1610                kind: "split".into(),
1611                config: json!({"field": "tags", "delimiter": ",", "trim": true}),
1612            },
1613            TransformSpec {
1614                kind: "join".into(),
1615                config: json!({"field": "tags", "delimiter": ",", "into": "csv"}),
1616            },
1617        ];
1618        let out = compile_transforms(&specs).unwrap();
1619        assert_eq!(out.len(), 2);
1620    }
1621
1622    #[cfg(feature = "transforms")]
1623    #[test]
1624    fn value_case_requires_mode() {
1625        let specs = vec![TransformSpec {
1626            kind: "value_case".into(),
1627            config: json!({"fields": ["email"]}),
1628        }];
1629        let err = compile_transforms(&specs).unwrap_err();
1630        match err {
1631            CliError::InvalidTransform { name, .. } => assert_eq!(name, "value_case"),
1632            other => panic!("expected InvalidTransform, got {other:?}"),
1633        }
1634    }
1635
1636    #[cfg(feature = "transforms")]
1637    #[test]
1638    fn available_transforms_lists_every_kind() {
1639        let names = available_transforms();
1640        for expected in [
1641            "flatten",
1642            "rename_keys",
1643            "keys_case",
1644            "select",
1645            "drop",
1646            "set",
1647            "rename_field",
1648            "cast",
1649            "redact",
1650            "value_case",
1651            "spell_symbols",
1652            "hash",
1653            "json_parse",
1654            "coalesce",
1655            "split",
1656            "join",
1657            "filter",
1658            "explode",
1659        ] {
1660            assert!(names.contains(&expected), "missing {expected}");
1661        }
1662        assert!(
1663            !names.contains(&"snake_case"),
1664            "snake_case must be removed in favour of keys_case"
1665        );
1666    }
1667
1668    #[cfg(feature = "transforms")]
1669    #[test]
1670    fn transform_descriptions_covers_every_compiled_kind() {
1671        // descriptions and available_transforms must never drift — `faucet list`
1672        // and the `UnknownTransform` "Available:" line both read from this.
1673        let names = available_transforms();
1674        let desc_names: Vec<&'static str> = transform_descriptions()
1675            .into_iter()
1676            .map(|(n, _)| n)
1677            .collect();
1678        assert_eq!(names, desc_names);
1679        for (_, desc) in transform_descriptions() {
1680            assert!(!desc.is_empty(), "every transform needs a description");
1681        }
1682    }
1683
1684    #[cfg(feature = "transforms")]
1685    #[test]
1686    fn transform_schema_returns_object_for_every_kind() {
1687        for name in available_transforms() {
1688            let schema = transform_schema(name).unwrap_or_else(|e| {
1689                panic!("schema lookup failed for {name}: {e}");
1690            });
1691            assert!(schema.is_object(), "schema for {name} must be an object");
1692        }
1693    }
1694
1695    #[cfg(feature = "transforms")]
1696    #[test]
1697    fn transform_schema_select_and_drop_share_shape() {
1698        // Both accept `{ fields: Vec<String> }` — the schema is the same object,
1699        // just titled `FieldsConfig`.
1700        let select = transform_schema("select").unwrap();
1701        let drop = transform_schema("drop").unwrap();
1702        assert_eq!(select, drop);
1703    }
1704
1705    #[test]
1706    fn transform_schema_unknown_errors_with_available_list() {
1707        let err = transform_schema("make_uppercase").unwrap_err();
1708        match err {
1709            CliError::UnknownTransform { name, available } => {
1710                assert_eq!(name, "make_uppercase");
1711                #[cfg(feature = "transforms")]
1712                assert!(available.contains("flatten"), "{available}");
1713                #[cfg(not(feature = "transforms"))]
1714                assert!(available.contains("rebuild"), "{available}");
1715            }
1716            other => panic!("expected UnknownTransform, got {other:?}"),
1717        }
1718    }
1719
1720    #[cfg(feature = "transform-filter")]
1721    #[test]
1722    fn compiles_filter_eq() {
1723        let specs = vec![TransformSpec {
1724            kind: "filter".into(),
1725            config: json!({"path": "status", "op": "eq", "value": "active"}),
1726        }];
1727        let out = compile_transforms(&specs).unwrap();
1728        assert_eq!(out.len(), 1);
1729        assert!(matches!(out[0], TransformStage::Filter(_)));
1730    }
1731
1732    #[cfg(feature = "transform-filter")]
1733    #[test]
1734    fn filter_rejects_in_with_non_array_value() {
1735        let specs = vec![TransformSpec {
1736            kind: "filter".into(),
1737            config: json!({"path": "v", "op": "in", "value": "scalar"}),
1738        }];
1739        let err = compile_transforms(&specs).unwrap_err();
1740        match err {
1741            CliError::InvalidTransform { name, message } => {
1742                assert_eq!(name, "filter");
1743                assert!(message.contains("requires an array"), "{message}");
1744            }
1745            other => panic!("expected InvalidTransform, got {other:?}"),
1746        }
1747    }
1748
1749    #[cfg(feature = "transform-filter")]
1750    #[test]
1751    fn filter_rejects_exists_with_value() {
1752        let specs = vec![TransformSpec {
1753            kind: "filter".into(),
1754            config: json!({"path": "v", "op": "exists", "value": "x"}),
1755        }];
1756        let err = compile_transforms(&specs).unwrap_err();
1757        match err {
1758            CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
1759            other => panic!("expected InvalidTransform, got {other:?}"),
1760        }
1761    }
1762
1763    #[cfg(feature = "transform-filter")]
1764    #[test]
1765    fn filter_rejects_bad_path() {
1766        let specs = vec![TransformSpec {
1767            kind: "filter".into(),
1768            config: json!({"path": "$..items", "op": "exists"}),
1769        }];
1770        let err = compile_transforms(&specs).unwrap_err();
1771        match err {
1772            CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
1773            other => panic!("expected InvalidTransform, got {other:?}"),
1774        }
1775    }
1776
1777    #[cfg(feature = "transform-explode")]
1778    #[test]
1779    fn compiles_explode_with_defaults() {
1780        let specs = vec![TransformSpec {
1781            kind: "explode".into(),
1782            config: json!({"path": "items"}),
1783        }];
1784        let out = compile_transforms(&specs).unwrap();
1785        assert_eq!(out.len(), 1);
1786        assert!(matches!(out[0], TransformStage::Explode(_)));
1787    }
1788
1789    #[cfg(feature = "transform-explode")]
1790    #[test]
1791    fn compiles_explode_with_custom_prefix_and_on_missing() {
1792        let specs = vec![TransformSpec {
1793            kind: "explode".into(),
1794            config: json!({
1795                "path": "items",
1796                "prefix": "item",
1797                "separator": "_",
1798                "on_missing": "drop"
1799            }),
1800        }];
1801        let out = compile_transforms(&specs).unwrap();
1802        assert_eq!(out.len(), 1);
1803    }
1804
1805    #[cfg(feature = "transform-explode")]
1806    #[test]
1807    fn explode_rejects_bad_path() {
1808        let specs = vec![TransformSpec {
1809            kind: "explode".into(),
1810            config: json!({"path": "$..items"}),
1811        }];
1812        let err = compile_transforms(&specs).unwrap_err();
1813        match err {
1814            CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
1815            other => panic!("expected InvalidTransform, got {other:?}"),
1816        }
1817    }
1818
1819    #[cfg(feature = "transform-explode")]
1820    #[test]
1821    fn explode_rejects_invalid_on_missing() {
1822        let specs = vec![TransformSpec {
1823            kind: "explode".into(),
1824            config: json!({"path": "items", "on_missing": "explode_harder"}),
1825        }];
1826        let err = compile_transforms(&specs).unwrap_err();
1827        match err {
1828            CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
1829            other => panic!("expected InvalidTransform, got {other:?}"),
1830        }
1831    }
1832
1833    #[cfg(feature = "transform-sql")]
1834    #[test]
1835    fn compiles_sql_to_page_fn() {
1836        let specs = vec![TransformSpec {
1837            kind: "sql".into(),
1838            config: json!({"query": "SELECT * FROM batch"}),
1839        }];
1840        let out = compile_transforms(&specs).unwrap();
1841        assert_eq!(out.len(), 1);
1842        assert!(matches!(out[0], faucet_core::TransformStage::PageFn(_)));
1843    }
1844
1845    #[cfg(feature = "transform-sql")]
1846    #[test]
1847    fn sql_bad_query_is_invalid_transform() {
1848        let specs = vec![TransformSpec {
1849            kind: "sql".into(),
1850            config: json!({"query": "SELEKT bad"}),
1851        }];
1852        let err = compile_transforms(&specs).unwrap_err();
1853        match err {
1854            CliError::InvalidTransform { name, .. } => assert_eq!(name, "sql"),
1855            other => panic!("expected InvalidTransform, got {other:?}"),
1856        }
1857    }
1858
1859    #[cfg(feature = "transform-sql")]
1860    #[test]
1861    fn sql_schema_and_listing_present() {
1862        assert!(transform_schema("sql").is_ok());
1863        assert!(available_transforms().contains(&"sql"));
1864    }
1865
1866    #[cfg(feature = "transform-wasm")]
1867    #[test]
1868    fn wasm_schema_and_listing_present() {
1869        assert!(transform_schema("wasm").is_ok());
1870        assert!(available_transforms().contains(&"wasm"));
1871    }
1872
1873    #[cfg(feature = "transform-wasm")]
1874    #[test]
1875    fn wasm_missing_module_field_is_invalid_transform() {
1876        let specs = vec![TransformSpec {
1877            kind: "wasm".into(),
1878            config: json!({}),
1879        }];
1880        let err = compile_transforms(&specs).unwrap_err();
1881        match err {
1882            CliError::InvalidTransform { name, .. } => assert_eq!(name, "wasm"),
1883            other => panic!("expected InvalidTransform, got {other:?}"),
1884        }
1885    }
1886
1887    #[cfg(feature = "transform-wasm")]
1888    #[test]
1889    fn wasm_nonexistent_module_is_invalid_transform() {
1890        let specs = vec![TransformSpec {
1891            kind: "wasm".into(),
1892            config: json!({"module": "/no/such/path/mod.wasm"}),
1893        }];
1894        let err = compile_transforms(&specs).unwrap_err();
1895        match err {
1896            CliError::InvalidTransform { name, message } => {
1897                assert_eq!(name, "wasm");
1898                assert!(message.contains("cannot read module"), "{message}");
1899            }
1900            other => panic!("expected InvalidTransform, got {other:?}"),
1901        }
1902    }
1903
1904    #[cfg(feature = "transform-cdc-unwrap")]
1905    #[test]
1906    fn compiles_cdc_unwrap_with_defaults() {
1907        let specs = vec![TransformSpec {
1908            kind: "cdc_unwrap".into(),
1909            config: json!({}),
1910        }];
1911        let out = compile_transforms(&specs).unwrap();
1912        assert_eq!(out.len(), 1);
1913        assert!(matches!(out[0], faucet_core::TransformStage::CdcUnwrap(_)));
1914    }
1915
1916    #[cfg(feature = "transform-cdc-unwrap")]
1917    #[test]
1918    fn cdc_unwrap_schema_and_listing_present() {
1919        assert!(transform_schema("cdc_unwrap").is_ok());
1920        assert!(available_transforms().contains(&"cdc_unwrap"));
1921    }
1922
1923    #[cfg(feature = "quality")]
1924    #[test]
1925    fn quality_descriptions_has_one_entry_per_check() {
1926        // 8 always-on per-record checks + 4 per-batch checks = 12; the
1927        // `json_schema` per-record check is only present (→ 13) when the
1928        // `quality-jsonschema` feature is enabled, matching `faucet schema
1929        // quality`. If you add a RecordCheck/BatchCheck variant in faucet-core,
1930        // add its description here too.
1931        #[cfg(feature = "quality-jsonschema")]
1932        assert_eq!(quality_descriptions().len(), 13);
1933        #[cfg(not(feature = "quality-jsonschema"))]
1934        assert_eq!(quality_descriptions().len(), 12);
1935    }
1936}