1use 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#[cfg(feature = "transforms")]
19#[derive(Debug, Deserialize, JsonSchema)]
20struct FlattenConfig {
21 #[serde(default = "default_separator")]
23 separator: String,
24}
25
26#[cfg(feature = "transforms")]
27fn default_separator() -> String {
28 "__".to_owned()
29}
30
31#[cfg(feature = "transforms")]
33#[derive(Debug, Deserialize, JsonSchema)]
34struct RenameKeysConfig {
35 pattern: String,
37 replacement: String,
39}
40
41#[cfg(feature = "transforms")]
42#[derive(Debug, Deserialize, JsonSchema)]
43struct FieldsConfig {
44 fields: Vec<String>,
46}
47
48#[cfg(feature = "transforms")]
49#[derive(Debug, Deserialize, JsonSchema)]
50struct SetConfig {
51 values: serde_json::Map<String, Value>,
53}
54
55#[cfg(feature = "transforms")]
56#[derive(Debug, Deserialize, JsonSchema)]
57struct RenameFieldConfig {
58 fields: HashMap<String, String>,
60}
61
62#[cfg(feature = "transforms")]
63#[derive(Debug, Deserialize, JsonSchema)]
64struct CastConfig {
65 fields: HashMap<String, CastType>,
67 #[serde(default)]
69 on_error: CastOnError,
70}
71
72#[cfg(feature = "transforms")]
73#[derive(Debug, Deserialize, JsonSchema)]
74struct RedactConfig {
75 fields: Vec<String>,
77 #[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 fields: Vec<String>,
92 mode: ValueCaseMode,
94}
95
96#[cfg(feature = "transforms")]
97#[derive(Debug, Deserialize, JsonSchema)]
98struct SpellSymbolsConfig {
99 #[serde(default)]
101 extra: HashMap<String, String>,
102 #[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 mode: KeyCaseMode,
117}
118
119#[cfg(feature = "transform-filter")]
120#[derive(Debug, Deserialize, JsonSchema)]
121struct FilterConfig {
122 path: String,
124 op: faucet_core::FilterOp,
126 #[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 path: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
139 prefix: Option<String>,
140 #[serde(default = "default_explode_separator_cli")]
142 separator: String,
143 #[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
154struct TransformDef {
161 kind: &'static str,
162 description: &'static str,
163 schema_fn: fn() -> Value,
164 compile_fn: fn(&str, Value) -> CliResult<TransformStage>,
165}
166
167fn 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 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
362pub 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
381pub fn transform_descriptions() -> Vec<(&'static str, &'static str)> {
384 registry()
385 .into_iter()
386 .map(|t| (t.kind, t.description))
387 .collect()
388}
389
390pub fn available_transforms() -> Vec<&'static str> {
392 registry().into_iter().map(|t| t.kind).collect()
393}
394
395#[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
428pub 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 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 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 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 #[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}