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