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 = "transforms")]
9use faucet_core::{CastOnError, CastType, KeyCaseMode, ValueCaseMode};
10#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
11use faucet_core::{JsonSchema, schema_for};
12use faucet_core::{RecordTransform, TransformStage};
13#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
14use serde::Deserialize;
15use serde_json::Value;
16#[cfg(feature = "transforms")]
17use std::collections::HashMap;
18
19/// Inline-config schema for the `flatten` transform.
20#[cfg(feature = "transforms")]
21#[derive(Debug, Deserialize, JsonSchema)]
22struct FlattenConfig {
23    /// Separator joining nested keys (default: `"__"`).
24    #[serde(default = "default_separator")]
25    separator: String,
26}
27
28#[cfg(feature = "transforms")]
29fn default_separator() -> String {
30    "__".to_owned()
31}
32
33/// Inline-config schema for the `rename_keys` transform.
34#[cfg(feature = "transforms")]
35#[derive(Debug, Deserialize, JsonSchema)]
36struct RenameKeysConfig {
37    /// Rust regex matched against every key.
38    pattern: String,
39    /// Replacement string. May reference capture groups (`$1`, `${name}`).
40    replacement: String,
41}
42
43#[cfg(feature = "transforms")]
44#[derive(Debug, Deserialize, JsonSchema)]
45struct FieldsConfig {
46    /// Top-level field names to act on.
47    fields: Vec<String>,
48}
49
50#[cfg(feature = "transforms")]
51#[derive(Debug, Deserialize, JsonSchema)]
52struct SetConfig {
53    /// Map of field name → constant value to set on every record.
54    values: serde_json::Map<String, Value>,
55}
56
57#[cfg(feature = "transforms")]
58#[derive(Debug, Deserialize, JsonSchema)]
59struct RenameFieldConfig {
60    /// Map of old field name → new field name.
61    fields: HashMap<String, String>,
62}
63
64#[cfg(feature = "transforms")]
65#[derive(Debug, Deserialize, JsonSchema)]
66struct CastConfig {
67    /// Map of field name → target type.
68    fields: HashMap<String, CastType>,
69    /// What to do when a value cannot be cast. Default: `error`.
70    #[serde(default)]
71    on_error: CastOnError,
72}
73
74#[cfg(feature = "transforms")]
75#[derive(Debug, Deserialize, JsonSchema)]
76struct RedactConfig {
77    /// Top-level field names to overwrite with `mask`.
78    fields: Vec<String>,
79    /// Replacement value. Default: the string `"***"`.
80    #[serde(default = "default_mask")]
81    mask: Value,
82}
83
84#[cfg(feature = "transforms")]
85fn default_mask() -> Value {
86    Value::String("***".to_owned())
87}
88
89#[cfg(feature = "transforms")]
90#[derive(Debug, Deserialize, JsonSchema)]
91struct ValueCaseConfig {
92    /// String-valued fields to re-case.
93    fields: Vec<String>,
94    /// Casing convention to apply to each listed field.
95    mode: ValueCaseMode,
96}
97
98#[cfg(feature = "transforms")]
99#[derive(Debug, Deserialize, JsonSchema)]
100struct SpellSymbolsConfig {
101    /// Extra symbol → word overrides layered on top of the built-in map.
102    #[serde(default)]
103    extra: HashMap<String, String>,
104    /// String inserted between expanded words. Default: a single space.
105    #[serde(default = "default_spell_separator")]
106    separator: String,
107}
108
109#[cfg(feature = "transforms")]
110fn default_spell_separator() -> String {
111    " ".to_owned()
112}
113
114#[cfg(feature = "transforms")]
115#[derive(Debug, Deserialize, JsonSchema)]
116struct KeysCaseConfig {
117    /// Output convention for every key in the record.
118    mode: KeyCaseMode,
119}
120
121#[cfg(feature = "transform-filter")]
122#[derive(Debug, Deserialize, JsonSchema)]
123struct FilterConfig {
124    /// JSONPath subset: bare key, dot path, or bracketed string key.
125    path: String,
126    /// One of `eq`, `ne`, `exists`, `in`, `not_in`.
127    op: faucet_core::FilterOp,
128    /// Required for `eq`/`ne`/`in`/`not_in`. For `in`/`not_in`, must be an array.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    value: Option<Value>,
131}
132
133#[cfg(feature = "transform-explode")]
134#[derive(Debug, Deserialize, JsonSchema)]
135struct ExplodeConfig {
136    /// JSONPath subset: bare key, dot path, or bracketed string key.
137    path: String,
138    /// Prefix prepended to object-element fields. Defaults to the last
139    /// segment of `path`. Empty string = pure LATERAL FLATTEN (no prefix).
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    prefix: Option<String>,
142    /// Separator between prefix and element field key. Default `"_"`.
143    #[serde(default = "default_explode_separator_cli")]
144    separator: String,
145    /// `passthrough` (default), `drop`, or `error` when path doesn't yield a
146    /// non-empty array.
147    #[serde(default)]
148    on_missing: faucet_core::OnMissing,
149}
150
151#[cfg(feature = "transform-explode")]
152fn default_explode_separator_cli() -> String {
153    "_".to_owned()
154}
155
156#[cfg(feature = "transform-cdc-unwrap")]
157#[derive(Debug, Deserialize, JsonSchema)]
158struct CdcUnwrapConfig {
159    /// Envelope field holding the operation code. Default `"op"`.
160    #[serde(default = "cdc_op_field")]
161    op_field: String,
162    /// Envelope field holding the post-image row. Default `"after"`.
163    #[serde(default = "cdc_after_field")]
164    after_field: String,
165    /// Envelope field holding the pre-image row. Default `"before"`.
166    #[serde(default = "cdc_before_field")]
167    before_field: String,
168    /// Fallback key field for deletes when `before` is absent. Default `"document_key"`.
169    #[serde(default = "cdc_key_field")]
170    key_field: String,
171    /// Field stamped onto every emitted row with the op value. Default `"__op"`.
172    #[serde(default = "cdc_marker_field")]
173    marker_field: String,
174    /// Op values that mean delete. Default `["d", "delete"]`.
175    #[serde(default = "cdc_delete_ops")]
176    delete_ops: Vec<String>,
177    /// Op values that cause the record to be dropped (1→0). Default `["ddl", "truncate"]`.
178    #[serde(default = "cdc_drop_ops")]
179    drop_ops: Vec<String>,
180}
181
182#[cfg(feature = "transform-cdc-unwrap")]
183fn cdc_op_field() -> String {
184    "op".into()
185}
186#[cfg(feature = "transform-cdc-unwrap")]
187fn cdc_after_field() -> String {
188    "after".into()
189}
190#[cfg(feature = "transform-cdc-unwrap")]
191fn cdc_before_field() -> String {
192    "before".into()
193}
194#[cfg(feature = "transform-cdc-unwrap")]
195fn cdc_key_field() -> String {
196    "document_key".into()
197}
198#[cfg(feature = "transform-cdc-unwrap")]
199fn cdc_marker_field() -> String {
200    "__op".into()
201}
202#[cfg(feature = "transform-cdc-unwrap")]
203fn cdc_delete_ops() -> Vec<String> {
204    vec!["d".into(), "delete".into()]
205}
206#[cfg(feature = "transform-cdc-unwrap")]
207fn cdc_drop_ops() -> Vec<String> {
208    vec!["ddl".into(), "truncate".into()]
209}
210
211/// One row in the transform registry — the single source of truth for every
212/// built-in transform's kind, one-line description, JSON Schema, and
213/// `TransformSpec → TransformStage` decoder. `compile_one`,
214/// `transform_descriptions`, and `transform_schema` all read from this list
215/// so adding a new transform means appending one entry (no parallel match
216/// arms to keep in sync).
217struct TransformDef {
218    kind: &'static str,
219    description: &'static str,
220    schema_fn: fn() -> Value,
221    compile_fn: fn(&str, Value) -> CliResult<TransformStage>,
222}
223
224/// Every transform compiled into this build, in display order.
225///
226/// Non-capturing closures coerce to the `fn` pointers held by `TransformDef`,
227/// so each row stays a single self-contained record next to its sibling
228/// entries.
229fn registry() -> Vec<TransformDef> {
230    #[allow(unused_mut)]
231    let mut defs: Vec<TransformDef> = Vec::new();
232    #[cfg(feature = "transforms")]
233    {
234        defs.extend(vec![
235            TransformDef {
236                kind: "flatten",
237                description: "Flatten nested objects into a single level (configurable separator).",
238                schema_fn: || schema::<FlattenConfig>(),
239                compile_fn: |kind, config| {
240                    let cfg = decode::<FlattenConfig>(kind, config)?;
241                    Ok(TransformStage::Map(RecordTransform::Flatten {
242                        separator: cfg.separator,
243                    }))
244                },
245            },
246            TransformDef {
247                kind: "rename_keys",
248                description: "Rewrite every key via a regex pattern + replacement.",
249                schema_fn: || schema::<RenameKeysConfig>(),
250                compile_fn: |kind, config| {
251                    let cfg = decode::<RenameKeysConfig>(kind, config)?;
252                    Ok(TransformStage::Map(RecordTransform::RenameKeys {
253                        pattern: cfg.pattern,
254                        replacement: cfg.replacement,
255                    }))
256                },
257            },
258            TransformDef {
259                kind: "keys_case",
260                description: "Re-case every key (snake / camel / pascal / kebab / screaming_snake).",
261                schema_fn: || schema::<KeysCaseConfig>(),
262                compile_fn: |kind, config| {
263                    let cfg = decode::<KeysCaseConfig>(kind, config)?;
264                    Ok(TransformStage::Map(RecordTransform::KeysCase {
265                        mode: cfg.mode,
266                    }))
267                },
268            },
269            TransformDef {
270                kind: "select",
271                description: "Keep only the listed top-level fields; drop the rest.",
272                schema_fn: || schema::<FieldsConfig>(),
273                compile_fn: |kind, config| {
274                    let cfg = decode::<FieldsConfig>(kind, config)?;
275                    Ok(TransformStage::Map(RecordTransform::Select {
276                        fields: cfg.fields,
277                    }))
278                },
279            },
280            TransformDef {
281                kind: "drop",
282                description: "Remove the listed top-level fields.",
283                schema_fn: || schema::<FieldsConfig>(),
284                compile_fn: |kind, config| {
285                    let cfg = decode::<FieldsConfig>(kind, config)?;
286                    Ok(TransformStage::Map(RecordTransform::Drop {
287                        fields: cfg.fields,
288                    }))
289                },
290            },
291            TransformDef {
292                kind: "set",
293                description: "Set named fields to constant values on every record.",
294                schema_fn: || schema::<SetConfig>(),
295                compile_fn: |kind, config| {
296                    let cfg = decode::<SetConfig>(kind, config)?;
297                    Ok(TransformStage::Map(RecordTransform::Set {
298                        values: cfg.values,
299                    }))
300                },
301            },
302            TransformDef {
303                kind: "rename_field",
304                description: "Rename specific top-level fields by name.",
305                schema_fn: || schema::<RenameFieldConfig>(),
306                compile_fn: |kind, config| {
307                    let cfg = decode::<RenameFieldConfig>(kind, config)?;
308                    Ok(TransformStage::Map(RecordTransform::RenameField {
309                        fields: cfg.fields,
310                    }))
311                },
312            },
313            TransformDef {
314                kind: "cast",
315                description: "Coerce named fields to int / float / bool / string / timestamp.",
316                schema_fn: || schema::<CastConfig>(),
317                compile_fn: |kind, config| {
318                    let cfg = decode::<CastConfig>(kind, config)?;
319                    Ok(TransformStage::Map(RecordTransform::Cast {
320                        fields: cfg.fields,
321                        on_error: cfg.on_error,
322                    }))
323                },
324            },
325            TransformDef {
326                kind: "redact",
327                description: "Overwrite the listed fields with a mask value (default `***`).",
328                schema_fn: || schema::<RedactConfig>(),
329                compile_fn: |kind, config| {
330                    let cfg = decode::<RedactConfig>(kind, config)?;
331                    Ok(TransformStage::Map(RecordTransform::Redact {
332                        fields: cfg.fields,
333                        mask: cfg.mask,
334                    }))
335                },
336            },
337            TransformDef {
338                kind: "value_case",
339                description: "Lowercase, uppercase, or trim the value of named string fields.",
340                schema_fn: || schema::<ValueCaseConfig>(),
341                compile_fn: |kind, config| {
342                    let cfg = decode::<ValueCaseConfig>(kind, config)?;
343                    Ok(TransformStage::Map(RecordTransform::ValueCase {
344                        fields: cfg.fields,
345                        mode: cfg.mode,
346                    }))
347                },
348            },
349            TransformDef {
350                kind: "spell_symbols",
351                description: "Replace punctuation/symbols in string values with their spelled-out words.",
352                schema_fn: || schema::<SpellSymbolsConfig>(),
353                compile_fn: |kind, config| {
354                    let cfg = decode::<SpellSymbolsConfig>(kind, config)?;
355                    Ok(TransformStage::Map(RecordTransform::SpellSymbols {
356                        extra: cfg.extra,
357                        separator: cfg.separator,
358                    }))
359                },
360            },
361            #[cfg(feature = "transform-filter")]
362            TransformDef {
363                kind: "filter",
364                description: "Keep records where a JSONPath predicate is true.",
365                schema_fn: || schema::<FilterConfig>(),
366                compile_fn: |kind, config| {
367                    let cfg = decode::<FilterConfig>(kind, config)?;
368                    // Re-use stage's compile-time validation so error messages match.
369                    let stage = TransformStage::Filter(faucet_core::FilterSpec {
370                        path: cfg.path,
371                        op: cfg.op,
372                        value: cfg.value,
373                    });
374                    faucet_core::compile_stage(&stage).map_err(|e| match e {
375                        faucet_core::FaucetError::Transform(msg) => CliError::InvalidTransform {
376                            name: kind.to_owned(),
377                            message: msg,
378                        },
379                        other => CliError::InvalidTransform {
380                            name: kind.to_owned(),
381                            message: format!("{other}"),
382                        },
383                    })?;
384                    Ok(stage)
385                },
386            },
387            #[cfg(feature = "transform-explode")]
388            TransformDef {
389                kind: "explode",
390                description: "Expand an array field into one record per element.",
391                schema_fn: || schema::<ExplodeConfig>(),
392                compile_fn: |kind, config| {
393                    let cfg = decode::<ExplodeConfig>(kind, config)?;
394                    let stage = TransformStage::Explode(faucet_core::ExplodeSpec {
395                        path: cfg.path,
396                        prefix: cfg.prefix,
397                        separator: cfg.separator,
398                        on_missing: cfg.on_missing,
399                    });
400                    faucet_core::compile_stage(&stage).map_err(|e| match e {
401                        faucet_core::FaucetError::Transform(msg) => CliError::InvalidTransform {
402                            name: kind.to_owned(),
403                            message: msg,
404                        },
405                        other => CliError::InvalidTransform {
406                            name: kind.to_owned(),
407                            message: format!("{other}"),
408                        },
409                    })?;
410                    Ok(stage)
411                },
412            },
413        ]);
414    }
415    #[cfg(feature = "transform-sql")]
416    {
417        defs.push(TransformDef {
418            kind: "sql",
419            description: "Run DuckDB SQL over the whole page; records are the `batch` relation.",
420            schema_fn: || schema_sql(),
421            compile_fn: |kind, config| {
422                let cfg: faucet_transform_sql::SqlTransformConfig = decode_sql(kind, config)?;
423                let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
424                    let message = match &e {
425                        faucet_core::FaucetError::Transform(m)
426                        | faucet_core::FaucetError::Config(m) => m.clone(),
427                        other => format!("{other}"),
428                    };
429                    CliError::InvalidTransform {
430                        name: kind.to_owned(),
431                        message,
432                    }
433                })?;
434                Ok(transform.into_page_stage())
435            },
436        });
437    }
438    #[cfg(feature = "transform-cdc-unwrap")]
439    {
440        defs.push(TransformDef {
441            kind: "cdc_unwrap",
442            description: "Normalize a CDC envelope into a flat row + delete marker (for upsert sinks).",
443            schema_fn: || schema::<CdcUnwrapConfig>(),
444            compile_fn: |kind, config| {
445                let cfg = decode::<CdcUnwrapConfig>(kind, config)?;
446                Ok(faucet_core::TransformStage::CdcUnwrap(faucet_core::CdcUnwrapSpec {
447                    op_field: cfg.op_field,
448                    after_field: cfg.after_field,
449                    before_field: cfg.before_field,
450                    key_field: cfg.key_field,
451                    marker_field: cfg.marker_field,
452                    delete_ops: cfg.delete_ops,
453                    drop_ops: cfg.drop_ops,
454                }))
455            },
456        });
457    }
458    defs
459}
460
461/// Compile a list of [`TransformSpec`]s into [`TransformStage`]s in the
462/// declared order. Most built-ins compile to a [`TransformStage::Map`];
463/// richer stages (e.g. `filter`, future fan-outs) compile to other
464/// variants. Unknown or malformed entries surface as a `CliError`.
465pub fn compile_transforms(specs: &[TransformSpec]) -> CliResult<Vec<TransformStage>> {
466    let mut out = Vec::with_capacity(specs.len());
467    for s in specs {
468        out.push(compile_one(s)?);
469    }
470    Ok(out)
471}
472
473fn compile_one(spec: &TransformSpec) -> CliResult<TransformStage> {
474    match registry().into_iter().find(|t| t.kind == spec.kind) {
475        Some(def) => (def.compile_fn)(&spec.kind, spec.config.clone()),
476        None => Err(unknown_transform(&spec.kind)),
477    }
478}
479
480/// Like [`compile_transforms`] but also returns each stage's Arrow
481/// `RecordBatch` form (parallel to the stages), so the executor can build a
482/// columnar-capable `TransformingSource` (#375). Only the `sql` transform
483/// supplies a batch form today; every other stage's entry is `None`, which
484/// keeps the whole chain on the `Value` path unless every stage is columnar.
485#[cfg(feature = "arrow")]
486pub fn compile_transforms_columnar(
487    specs: &[TransformSpec],
488) -> CliResult<(
489    Vec<TransformStage>,
490    Vec<Option<faucet_core::stage::PageFnBatchBox>>,
491)> {
492    let mut stages = Vec::with_capacity(specs.len());
493    let mut batches = Vec::with_capacity(specs.len());
494    for s in specs {
495        #[cfg(feature = "transform-sql")]
496        if s.kind == "sql" {
497            let cfg = decode_sql("sql", s.config.clone())?;
498            let transform = faucet_transform_sql::SqlTransform::compile(&cfg).map_err(|e| {
499                let message = match &e {
500                    faucet_core::FaucetError::Transform(m)
501                    | faucet_core::FaucetError::Config(m) => m.clone(),
502                    other => format!("{other}"),
503                };
504                CliError::InvalidTransform {
505                    name: "sql".to_owned(),
506                    message,
507                }
508            })?;
509            let (stage, batch) = transform.into_columnar_stage();
510            stages.push(stage);
511            batches.push(Some(batch));
512            continue;
513        }
514        stages.push(compile_one(s)?);
515        batches.push(None);
516    }
517    Ok((stages, batches))
518}
519
520/// One-line summary of every transform compiled into this build. Used by
521/// `faucet list`.
522pub fn transform_descriptions() -> Vec<(&'static str, &'static str)> {
523    registry()
524        .into_iter()
525        .map(|t| (t.kind, t.description))
526        .collect()
527}
528
529/// Names of every transform compiled into this build.
530pub fn available_transforms() -> Vec<&'static str> {
531    registry().into_iter().map(|t| t.kind).collect()
532}
533
534// Keep in sync with faucet_core::{RecordCheck, BatchCheck} — one entry per check variant.
535/// One-line descriptions of the available quality checks, for `faucet list`.
536/// The `json_schema` entry only appears when the `quality-jsonschema` feature
537/// is enabled, mirroring `faucet schema quality` so `list` and `schema` agree.
538#[cfg(feature = "quality")]
539pub fn quality_descriptions() -> Vec<(&'static str, &'static str)> {
540    let mut checks = vec![
541        ("not_null", "field present and non-null"),
542        ("not_empty", "string non-empty after trim"),
543        ("regex_match", "string matches a regex"),
544        ("value_in_set", "value is in an allowed set"),
545        ("not_in_set", "value is not in a forbidden set"),
546        ("compare", "numeric/scalar comparison (gt/gte/lt/lte/eq/ne)"),
547        ("type_is", "value is of an expected JSON type"),
548        ("string_length", "string length within [min,max]"),
549    ];
550    #[cfg(feature = "quality-jsonschema")]
551    checks.push((
552        "json_schema",
553        "record validates against a JSON Schema (feature-gated)",
554    ));
555    checks.extend([
556        ("row_count", "batch row count within [min,max]"),
557        ("null_rate", "batch null rate of a field <= max"),
558        ("unique", "composite key unique within the batch"),
559        (
560            "distinct_count",
561            "distinct values of a field within [min,max]",
562        ),
563    ]);
564    checks
565}
566
567/// Return the JSON Schema for the named transform's config. Mirrors
568/// `registry::source_schema` / `sink_schema` so `faucet schema transform <name>`
569/// reads symmetrically with the connector variants.
570pub fn transform_schema(name: &str) -> CliResult<Value> {
571    registry()
572        .into_iter()
573        .find(|t| t.kind == name)
574        .map(|t| (t.schema_fn)())
575        .ok_or_else(|| unknown_transform(name))
576}
577
578fn unknown_transform(name: &str) -> CliError {
579    let available = available_transforms();
580    CliError::UnknownTransform {
581        name: name.to_owned(),
582        available: if available.is_empty() {
583            "(none — rebuild faucet-cli with the `transforms` feature enabled)".to_owned()
584        } else {
585            available.join(", ")
586        },
587    }
588}
589
590#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
591fn schema<T: JsonSchema>() -> Value {
592    serde_json::to_value(schema_for!(T)).unwrap_or_else(|_| serde_json::json!({"type": "object"}))
593}
594
595#[cfg(any(feature = "transforms", feature = "transform-cdc-unwrap"))]
596fn decode<T: serde::de::DeserializeOwned>(name: &str, config: Value) -> CliResult<T> {
597    serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
598        name: name.to_owned(),
599        message: e.to_string(),
600    })
601}
602
603#[cfg(feature = "transform-sql")]
604fn schema_sql() -> Value {
605    serde_json::to_value(faucet_core::schema_for!(
606        faucet_transform_sql::SqlTransformConfig
607    ))
608    .unwrap_or(Value::Null)
609}
610
611#[cfg(feature = "transform-sql")]
612fn decode_sql(kind: &str, config: Value) -> CliResult<faucet_transform_sql::SqlTransformConfig> {
613    serde_json::from_value(config).map_err(|e| CliError::InvalidTransform {
614        name: kind.to_owned(),
615        message: e.to_string(),
616    })
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use serde_json::json;
623
624    #[test]
625    fn empty_list_compiles_to_empty() {
626        let out = compile_transforms(&[]).unwrap();
627        assert!(out.is_empty());
628    }
629
630    #[cfg(feature = "transforms")]
631    #[test]
632    fn compiles_keys_case_and_flatten() {
633        let specs = vec![
634            TransformSpec {
635                kind: "keys_case".into(),
636                config: json!({"mode": "snake"}),
637            },
638            TransformSpec {
639                kind: "flatten".into(),
640                config: json!({"separator": "."}),
641            },
642        ];
643        let out = compile_transforms(&specs).unwrap();
644        assert_eq!(out.len(), 2);
645    }
646
647    #[cfg(feature = "transforms")]
648    #[test]
649    fn keys_case_rejects_unknown_mode() {
650        let specs = vec![TransformSpec {
651            kind: "keys_case".into(),
652            config: json!({"mode": "spongebob"}),
653        }];
654        let err = compile_transforms(&specs).unwrap_err();
655        match err {
656            CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
657            other => panic!("expected InvalidTransform, got {other:?}"),
658        }
659    }
660
661    #[cfg(feature = "transforms")]
662    #[test]
663    fn keys_case_requires_mode() {
664        let specs = vec![TransformSpec {
665            kind: "keys_case".into(),
666            config: json!({}),
667        }];
668        let err = compile_transforms(&specs).unwrap_err();
669        match err {
670            CliError::InvalidTransform { name, .. } => assert_eq!(name, "keys_case"),
671            other => panic!("expected InvalidTransform, got {other:?}"),
672        }
673    }
674
675    #[cfg(feature = "transforms")]
676    #[test]
677    fn snake_case_kind_is_no_longer_recognized() {
678        // Removed in favour of `keys_case { mode: snake }`.
679        let specs = vec![TransformSpec {
680            kind: "snake_case".into(),
681            config: json!({}),
682        }];
683        let err = compile_transforms(&specs).unwrap_err();
684        match err {
685            CliError::UnknownTransform { name, .. } => assert_eq!(name, "snake_case"),
686            other => panic!("expected UnknownTransform, got {other:?}"),
687        }
688    }
689
690    #[cfg(feature = "transforms")]
691    #[test]
692    fn rename_keys_requires_pattern_and_replacement() {
693        let specs = vec![TransformSpec {
694            kind: "rename_keys".into(),
695            config: json!({"pattern": "^_"}),
696        }];
697        let err = compile_transforms(&specs).unwrap_err();
698        match err {
699            CliError::InvalidTransform { name, .. } => assert_eq!(name, "rename_keys"),
700            other => panic!("expected InvalidTransform, got {other:?}"),
701        }
702    }
703
704    #[test]
705    fn unknown_transform_errors() {
706        let specs = vec![TransformSpec {
707            kind: "make_uppercase".into(),
708            config: json!({}),
709        }];
710        let err = compile_transforms(&specs).unwrap_err();
711        match err {
712            CliError::UnknownTransform { name, .. } => assert_eq!(name, "make_uppercase"),
713            other => panic!("expected UnknownTransform, got {other:?}"),
714        }
715    }
716
717    #[cfg(feature = "transforms")]
718    #[test]
719    fn compiles_select_and_drop() {
720        let specs = vec![
721            TransformSpec {
722                kind: "select".into(),
723                config: json!({"fields": ["id", "name"]}),
724            },
725            TransformSpec {
726                kind: "drop".into(),
727                config: json!({"fields": ["secret"]}),
728            },
729        ];
730        let out = compile_transforms(&specs).unwrap();
731        assert_eq!(out.len(), 2);
732    }
733
734    #[cfg(feature = "transforms")]
735    #[test]
736    fn compiles_set_with_object_values() {
737        let specs = vec![TransformSpec {
738            kind: "set".into(),
739            config: json!({"values": {"_source": "api", "version": 1}}),
740        }];
741        let out = compile_transforms(&specs).unwrap();
742        assert_eq!(out.len(), 1);
743    }
744
745    #[cfg(feature = "transforms")]
746    #[test]
747    fn compiles_rename_field() {
748        let specs = vec![TransformSpec {
749            kind: "rename_field".into(),
750            config: json!({"fields": {"old": "new"}}),
751        }];
752        let out = compile_transforms(&specs).unwrap();
753        assert_eq!(out.len(), 1);
754    }
755
756    #[cfg(feature = "transforms")]
757    #[test]
758    fn compiles_cast_with_default_on_error() {
759        let specs = vec![TransformSpec {
760            kind: "cast".into(),
761            config: json!({"fields": {"age": "int", "price": "float"}}),
762        }];
763        let out = compile_transforms(&specs).unwrap();
764        assert_eq!(out.len(), 1);
765    }
766
767    #[cfg(feature = "transforms")]
768    #[test]
769    fn cast_rejects_unknown_target_type() {
770        let specs = vec![TransformSpec {
771            kind: "cast".into(),
772            config: json!({"fields": {"x": "uuid"}}),
773        }];
774        let err = compile_transforms(&specs).unwrap_err();
775        match err {
776            CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
777            other => panic!("expected InvalidTransform, got {other:?}"),
778        }
779    }
780
781    #[cfg(feature = "transforms")]
782    #[test]
783    fn cast_rejects_unknown_on_error_mode() {
784        let specs = vec![TransformSpec {
785            kind: "cast".into(),
786            config: json!({"fields": {"x": "int"}, "on_error": "explode"}),
787        }];
788        let err = compile_transforms(&specs).unwrap_err();
789        match err {
790            CliError::InvalidTransform { name, .. } => assert_eq!(name, "cast"),
791            other => panic!("expected InvalidTransform, got {other:?}"),
792        }
793    }
794
795    #[cfg(feature = "transforms")]
796    #[test]
797    fn redact_uses_default_mask_when_omitted() {
798        let specs = vec![TransformSpec {
799            kind: "redact".into(),
800            config: json!({"fields": ["ssn"]}),
801        }];
802        let out = compile_transforms(&specs).unwrap();
803        assert_eq!(out.len(), 1);
804    }
805
806    #[cfg(feature = "transforms")]
807    #[test]
808    fn value_case_requires_mode() {
809        let specs = vec![TransformSpec {
810            kind: "value_case".into(),
811            config: json!({"fields": ["email"]}),
812        }];
813        let err = compile_transforms(&specs).unwrap_err();
814        match err {
815            CliError::InvalidTransform { name, .. } => assert_eq!(name, "value_case"),
816            other => panic!("expected InvalidTransform, got {other:?}"),
817        }
818    }
819
820    #[cfg(feature = "transforms")]
821    #[test]
822    fn available_transforms_lists_every_kind() {
823        let names = available_transforms();
824        for expected in [
825            "flatten",
826            "rename_keys",
827            "keys_case",
828            "select",
829            "drop",
830            "set",
831            "rename_field",
832            "cast",
833            "redact",
834            "value_case",
835            "spell_symbols",
836            "filter",
837            "explode",
838        ] {
839            assert!(names.contains(&expected), "missing {expected}");
840        }
841        assert!(
842            !names.contains(&"snake_case"),
843            "snake_case must be removed in favour of keys_case"
844        );
845    }
846
847    #[cfg(feature = "transforms")]
848    #[test]
849    fn transform_descriptions_covers_every_compiled_kind() {
850        // descriptions and available_transforms must never drift — `faucet list`
851        // and the `UnknownTransform` "Available:" line both read from this.
852        let names = available_transforms();
853        let desc_names: Vec<&'static str> = transform_descriptions()
854            .into_iter()
855            .map(|(n, _)| n)
856            .collect();
857        assert_eq!(names, desc_names);
858        for (_, desc) in transform_descriptions() {
859            assert!(!desc.is_empty(), "every transform needs a description");
860        }
861    }
862
863    #[cfg(feature = "transforms")]
864    #[test]
865    fn transform_schema_returns_object_for_every_kind() {
866        for name in available_transforms() {
867            let schema = transform_schema(name).unwrap_or_else(|e| {
868                panic!("schema lookup failed for {name}: {e}");
869            });
870            assert!(schema.is_object(), "schema for {name} must be an object");
871        }
872    }
873
874    #[cfg(feature = "transforms")]
875    #[test]
876    fn transform_schema_select_and_drop_share_shape() {
877        // Both accept `{ fields: Vec<String> }` — the schema is the same object,
878        // just titled `FieldsConfig`.
879        let select = transform_schema("select").unwrap();
880        let drop = transform_schema("drop").unwrap();
881        assert_eq!(select, drop);
882    }
883
884    #[test]
885    fn transform_schema_unknown_errors_with_available_list() {
886        let err = transform_schema("make_uppercase").unwrap_err();
887        match err {
888            CliError::UnknownTransform { name, available } => {
889                assert_eq!(name, "make_uppercase");
890                #[cfg(feature = "transforms")]
891                assert!(available.contains("flatten"), "{available}");
892                #[cfg(not(feature = "transforms"))]
893                assert!(available.contains("rebuild"), "{available}");
894            }
895            other => panic!("expected UnknownTransform, got {other:?}"),
896        }
897    }
898
899    #[cfg(feature = "transform-filter")]
900    #[test]
901    fn compiles_filter_eq() {
902        let specs = vec![TransformSpec {
903            kind: "filter".into(),
904            config: json!({"path": "status", "op": "eq", "value": "active"}),
905        }];
906        let out = compile_transforms(&specs).unwrap();
907        assert_eq!(out.len(), 1);
908        assert!(matches!(out[0], TransformStage::Filter(_)));
909    }
910
911    #[cfg(feature = "transform-filter")]
912    #[test]
913    fn filter_rejects_in_with_non_array_value() {
914        let specs = vec![TransformSpec {
915            kind: "filter".into(),
916            config: json!({"path": "v", "op": "in", "value": "scalar"}),
917        }];
918        let err = compile_transforms(&specs).unwrap_err();
919        match err {
920            CliError::InvalidTransform { name, message } => {
921                assert_eq!(name, "filter");
922                assert!(message.contains("requires an array"), "{message}");
923            }
924            other => panic!("expected InvalidTransform, got {other:?}"),
925        }
926    }
927
928    #[cfg(feature = "transform-filter")]
929    #[test]
930    fn filter_rejects_exists_with_value() {
931        let specs = vec![TransformSpec {
932            kind: "filter".into(),
933            config: json!({"path": "v", "op": "exists", "value": "x"}),
934        }];
935        let err = compile_transforms(&specs).unwrap_err();
936        match err {
937            CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
938            other => panic!("expected InvalidTransform, got {other:?}"),
939        }
940    }
941
942    #[cfg(feature = "transform-filter")]
943    #[test]
944    fn filter_rejects_bad_path() {
945        let specs = vec![TransformSpec {
946            kind: "filter".into(),
947            config: json!({"path": "$..items", "op": "exists"}),
948        }];
949        let err = compile_transforms(&specs).unwrap_err();
950        match err {
951            CliError::InvalidTransform { name, .. } => assert_eq!(name, "filter"),
952            other => panic!("expected InvalidTransform, got {other:?}"),
953        }
954    }
955
956    #[cfg(feature = "transform-explode")]
957    #[test]
958    fn compiles_explode_with_defaults() {
959        let specs = vec![TransformSpec {
960            kind: "explode".into(),
961            config: json!({"path": "items"}),
962        }];
963        let out = compile_transforms(&specs).unwrap();
964        assert_eq!(out.len(), 1);
965        assert!(matches!(out[0], TransformStage::Explode(_)));
966    }
967
968    #[cfg(feature = "transform-explode")]
969    #[test]
970    fn compiles_explode_with_custom_prefix_and_on_missing() {
971        let specs = vec![TransformSpec {
972            kind: "explode".into(),
973            config: json!({
974                "path": "items",
975                "prefix": "item",
976                "separator": "_",
977                "on_missing": "drop"
978            }),
979        }];
980        let out = compile_transforms(&specs).unwrap();
981        assert_eq!(out.len(), 1);
982    }
983
984    #[cfg(feature = "transform-explode")]
985    #[test]
986    fn explode_rejects_bad_path() {
987        let specs = vec![TransformSpec {
988            kind: "explode".into(),
989            config: json!({"path": "$..items"}),
990        }];
991        let err = compile_transforms(&specs).unwrap_err();
992        match err {
993            CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
994            other => panic!("expected InvalidTransform, got {other:?}"),
995        }
996    }
997
998    #[cfg(feature = "transform-explode")]
999    #[test]
1000    fn explode_rejects_invalid_on_missing() {
1001        let specs = vec![TransformSpec {
1002            kind: "explode".into(),
1003            config: json!({"path": "items", "on_missing": "explode_harder"}),
1004        }];
1005        let err = compile_transforms(&specs).unwrap_err();
1006        match err {
1007            CliError::InvalidTransform { name, .. } => assert_eq!(name, "explode"),
1008            other => panic!("expected InvalidTransform, got {other:?}"),
1009        }
1010    }
1011
1012    #[cfg(feature = "transform-sql")]
1013    #[test]
1014    fn compiles_sql_to_page_fn() {
1015        let specs = vec![TransformSpec {
1016            kind: "sql".into(),
1017            config: json!({"query": "SELECT * FROM batch"}),
1018        }];
1019        let out = compile_transforms(&specs).unwrap();
1020        assert_eq!(out.len(), 1);
1021        assert!(matches!(out[0], faucet_core::TransformStage::PageFn(_)));
1022    }
1023
1024    #[cfg(feature = "transform-sql")]
1025    #[test]
1026    fn sql_bad_query_is_invalid_transform() {
1027        let specs = vec![TransformSpec {
1028            kind: "sql".into(),
1029            config: json!({"query": "SELEKT bad"}),
1030        }];
1031        let err = compile_transforms(&specs).unwrap_err();
1032        match err {
1033            CliError::InvalidTransform { name, .. } => assert_eq!(name, "sql"),
1034            other => panic!("expected InvalidTransform, got {other:?}"),
1035        }
1036    }
1037
1038    #[cfg(feature = "transform-sql")]
1039    #[test]
1040    fn sql_schema_and_listing_present() {
1041        assert!(transform_schema("sql").is_ok());
1042        assert!(available_transforms().contains(&"sql"));
1043    }
1044
1045    #[cfg(feature = "transform-cdc-unwrap")]
1046    #[test]
1047    fn compiles_cdc_unwrap_with_defaults() {
1048        let specs = vec![TransformSpec {
1049            kind: "cdc_unwrap".into(),
1050            config: json!({}),
1051        }];
1052        let out = compile_transforms(&specs).unwrap();
1053        assert_eq!(out.len(), 1);
1054        assert!(matches!(out[0], faucet_core::TransformStage::CdcUnwrap(_)));
1055    }
1056
1057    #[cfg(feature = "transform-cdc-unwrap")]
1058    #[test]
1059    fn cdc_unwrap_schema_and_listing_present() {
1060        assert!(transform_schema("cdc_unwrap").is_ok());
1061        assert!(available_transforms().contains(&"cdc_unwrap"));
1062    }
1063
1064    #[cfg(feature = "quality")]
1065    #[test]
1066    fn quality_descriptions_has_one_entry_per_check() {
1067        // 8 always-on per-record checks + 4 per-batch checks = 12; the
1068        // `json_schema` per-record check is only present (→ 13) when the
1069        // `quality-jsonschema` feature is enabled, matching `faucet schema
1070        // quality`. If you add a RecordCheck/BatchCheck variant in faucet-core,
1071        // add its description here too.
1072        #[cfg(feature = "quality-jsonschema")]
1073        assert_eq!(quality_descriptions().len(), 13);
1074        #[cfg(not(feature = "quality-jsonschema"))]
1075        assert_eq!(quality_descriptions().len(), 12);
1076    }
1077}