1use crate::error::{CliError, CliResult};
32use crate::interpolate::interpolate;
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36use std::collections::HashMap;
37use std::path::{Path, PathBuf};
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct PipelineConfig {
43 #[serde(default = "default_version")]
45 pub version: u32,
46
47 #[serde(default)]
49 pub name: Option<String>,
50
51 #[serde(default)]
55 pub vars: Option<HashMap<String, Value>>,
56
57 #[serde(default)]
62 pub auth: Option<HashMap<String, Value>>,
63
64 pub pipeline: PipelineSpec,
66
67 #[serde(default)]
70 pub matrix: Vec<MatrixRow>,
71
72 #[serde(default)]
74 pub execution: Option<ExecutionSpec>,
75
76 #[serde(default)]
78 pub observability: Option<ObservabilitySpec>,
79
80 #[serde(default)]
85 pub delivery: faucet_core::DeliveryMode,
86
87 #[cfg(feature = "schedule")]
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
92
93 #[cfg(feature = "lineage")]
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub lineage: Option<faucet_lineage::LineageConfig>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct PipelineSpec {
105 #[serde(default)]
108 pub source: Option<ConnectorSpec>,
109
110 #[serde(default)]
112 pub sink: Option<ConnectorSpec>,
113
114 #[serde(default)]
116 pub sources: HashMap<String, ConnectorSpec>,
117
118 #[serde(default)]
120 pub sinks: HashMap<String, ConnectorSpec>,
121
122 #[serde(default)]
123 pub transforms: Vec<TransformSpec>,
124 #[serde(default)]
125 pub state: Option<StateStoreSpec>,
126 #[serde(default)]
127 pub dlq: Option<DlqSpec>,
128
129 #[cfg(feature = "quality")]
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub quality: Option<faucet_core::QualitySpec>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
140#[serde(deny_unknown_fields)]
141pub struct ConnectorSpec {
142 #[serde(rename = "type")]
145 pub kind: String,
146
147 #[serde(default = "empty_object")]
150 pub config: Value,
151
152 #[serde(default)]
156 pub transforms: Option<Vec<TransformSpec>>,
157
158 #[serde(default = "default_true")]
162 pub inherit_transforms: bool,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct PartialConnector {
174 #[serde(default)]
177 pub r#ref: Option<String>,
178 #[serde(rename = "type", default)]
180 pub kind: Option<String>,
181 #[serde(default)]
183 pub config: Option<Value>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
188#[serde(deny_unknown_fields)]
189pub struct TransformSpec {
190 #[serde(rename = "type")]
195 pub kind: String,
196
197 #[serde(default = "empty_object")]
199 pub config: Value,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct StateStoreSpec {
206 #[serde(rename = "type")]
208 pub kind: String,
209
210 #[serde(default = "empty_object")]
212 pub config: Value,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct MatrixRow {
219 #[serde(default)]
222 pub id: Option<String>,
223
224 #[serde(default)]
226 pub parent: Option<String>,
227
228 #[serde(default = "default_parent_key")]
231 pub parent_key: String,
232
233 #[serde(default)]
235 pub source: Option<PartialConnector>,
236
237 #[serde(default)]
239 pub sink: Option<PartialConnector>,
240
241 #[serde(default)]
246 pub transforms: Option<Vec<TransformSpec>>,
247
248 #[serde(default = "default_true")]
251 pub inherit_transforms: bool,
252
253 #[serde(default)]
255 pub state: Option<StateStoreSpec>,
256
257 #[serde(default, deserialize_with = "deserialize_dlq_override")]
262 pub dlq: Option<Option<DlqSpec>>,
263
264 #[serde(default)]
266 pub delivery: Option<faucet_core::DeliveryMode>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct ExecutionSpec {
273 #[serde(default)]
277 pub max_concurrent: Option<usize>,
278
279 #[serde(default)]
281 pub on_error: OnError,
282
283 #[serde(default)]
285 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
286}
287
288#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
290#[serde(rename_all = "lowercase")]
291pub enum OnError {
292 #[default]
294 Continue,
295 Stop,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
301pub struct ObservabilitySpec {
302 #[serde(default)]
304 pub prometheus: Option<PrometheusSpec>,
305
306 #[serde(default)]
308 pub tracing: Option<TracingSpec>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313pub struct PrometheusSpec {
314 pub listen: String,
316
317 #[serde(default)]
320 pub buckets: Option<Vec<f64>>,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325pub struct TracingSpec {
326 #[serde(default)]
329 pub level: Option<String>,
330}
331
332#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
336#[serde(rename_all = "snake_case")]
337pub enum OnBatchErrorSpec {
338 #[default]
339 Propagate,
340 DlqAll,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
345#[serde(deny_unknown_fields)]
346pub struct DlqSpec {
347 pub sink: ConnectorSpec,
348 #[serde(default)]
349 pub on_batch_error: OnBatchErrorSpec,
350 #[serde(default)]
351 pub max_failures_per_page: Option<usize>,
352 #[serde(default)]
353 pub max_failures_total: Option<usize>,
354 #[serde(default = "default_true")]
355 pub include_original_payload: bool,
356}
357
358fn default_true() -> bool {
359 true
360}
361
362fn default_version() -> u32 {
363 1
364}
365fn default_parent_key() -> String {
366 "id".to_owned()
367}
368fn empty_object() -> Value {
369 Value::Object(Default::default())
370}
371
372fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
373where
374 D: serde::Deserializer<'de>,
375{
376 Option::<DlqSpec>::deserialize(deserializer).map(Some)
377}
378
379impl PipelineConfig {
380 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
393 let path = path.as_ref();
394 let composed = crate::compose::compose(path, profile)?;
395 let interpolated = interpolate(&composed)?;
396 let cfg = Self::from_text(&interpolated, path)?;
397 crate::secrets::ensure_no_secret_directives(&cfg)?;
400 Ok(cfg)
401 }
402
403 pub fn from_path_tolerating_secrets(
407 path: impl AsRef<Path>,
408 profile: Option<&str>,
409 ) -> CliResult<Self> {
410 let path = path.as_ref();
411 let composed = crate::compose::compose(path, profile)?;
412 let interpolated = interpolate(&composed)?;
413 Self::from_text(&interpolated, path)
414 }
415
416 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
420 let path = path.as_ref();
421 let composed = crate::compose::compose(path, profile)?;
422 let interpolated = interpolate(&composed)?;
423 let mut cfg = Self::from_text(&interpolated, path)?;
424 crate::secrets::resolve_secrets(&mut cfg).await?;
425 Ok(cfg)
426 }
427
428 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
431 let ext = path
432 .extension()
433 .and_then(|e| e.to_str())
434 .map(str::to_ascii_lowercase);
435 let cfg: PipelineConfig = match ext.as_deref() {
436 Some("yaml" | "yml") => {
437 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
438 path: path.to_path_buf(),
439 message: friendly_parse_error(&e.to_string()),
440 })?
441 }
442 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
443 path: path.to_path_buf(),
444 message: friendly_parse_error(&e.to_string()),
445 })?,
446 _ => {
447 return Err(CliError::UnknownExtension {
448 path: path.to_path_buf(),
449 });
450 }
451 };
452 Self::finish(cfg, path)
453 }
454
455 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
463 let synthetic = Path::new("<submitted>");
464 let cfg: PipelineConfig =
465 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
466 path: synthetic.to_path_buf(),
467 message: friendly_parse_error(&e.to_string()),
468 })?;
469 Self::finish(cfg, synthetic)
470 }
471
472 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
474 if cfg.version != 1 {
475 return Err(CliError::ParseConfig {
476 path: path.to_path_buf(),
477 message: format!(
478 "unsupported pipeline version {}, only version 1 is recognised",
479 cfg.version
480 ),
481 });
482 }
483 crate::interpolate::resolve_config_refs(&mut cfg)?;
484 Ok(cfg)
485 }
486}
487
488fn friendly_parse_error(raw: &str) -> String {
491 let lower = raw.to_ascii_lowercase();
492 if lower.contains("missing field `pipeline`") {
493 return format!(
494 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
495 );
496 }
497 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
498 return format!(
499 "{raw}\n\nhint: config composition (`extends` / `profiles` / `!include`) is resolved only for file-based loads, not for configs submitted to `faucet serve` — resolve composition before submitting."
500 );
501 }
502 raw.to_owned()
503}
504
505pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
508 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
509 PipelineConfig::from_text(text, &synthetic)
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use serde_json::json;
516
517 #[test]
518 fn parses_minimal_pipeline_yaml() {
519 let yaml = r#"
520version: 1
521pipeline:
522 source:
523 type: rest
524 config:
525 base_url: https://api.example.com
526 sink:
527 type: jsonl
528 config:
529 path: ./out.jsonl
530"#;
531 let cfg = parse_with_extension(yaml, "yaml").unwrap();
532 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
533 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
534 assert!(cfg.matrix.is_empty());
535 assert!(cfg.execution.is_none());
536 assert!(cfg.pipeline.transforms.is_empty());
537 assert!(cfg.pipeline.state.is_none());
538 }
539
540 #[test]
541 fn parses_minimal_json() {
542 let raw = r#"{
543 "version": 1,
544 "pipeline": {
545 "source": {"type": "rest", "config": {}},
546 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
547 }
548 }"#;
549 let cfg = parse_with_extension(raw, "json").unwrap();
550 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
551 }
552
553 #[test]
554 fn parses_matrix_rows_with_partial_overrides() {
555 let yaml = r#"
556version: 1
557pipeline:
558 source: { type: rest, config: { base_url: https://api.example.com } }
559 sink: { type: jsonl, config: { path: ./out.jsonl } }
560matrix:
561 - id: users
562 source: { config: { path: /v1/users } }
563 sink: { config: { path: ./users.jsonl } }
564 - id: posts
565 parent: users
566 parent_key: user_id
567 source: { config: { path: "/v1/users/${users.id}/posts" } }
568"#;
569 let cfg = parse_with_extension(yaml, "yaml").unwrap();
570 assert_eq!(cfg.matrix.len(), 2);
571 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
572 assert!(cfg.matrix[0].parent.is_none());
573 let users_src = cfg.matrix[0].source.as_ref().unwrap();
574 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
575
576 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
577 assert_eq!(cfg.matrix[1].parent_key, "user_id");
578 }
579
580 #[test]
581 fn parent_key_defaults_to_id() {
582 let yaml = r#"
583version: 1
584pipeline:
585 source: { type: rest, config: {} }
586 sink: { type: jsonl, config: { path: ./o.jsonl } }
587matrix:
588 - { id: users }
589 - { id: posts, parent: users }
590"#;
591 let cfg = parse_with_extension(yaml, "yaml").unwrap();
592 assert_eq!(cfg.matrix[1].parent_key, "id");
593 }
594
595 #[test]
596 fn parses_execution_block() {
597 let yaml = r#"
598version: 1
599pipeline:
600 source: { type: rest, config: {} }
601 sink: { type: jsonl, config: { path: ./o.jsonl } }
602execution:
603 max_concurrent: 8
604 on_error: stop
605"#;
606 let cfg = parse_with_extension(yaml, "yaml").unwrap();
607 let exec = cfg.execution.unwrap();
608 assert_eq!(exec.max_concurrent, Some(8));
609 assert_eq!(exec.on_error, OnError::Stop);
610 }
611
612 #[test]
613 fn on_error_defaults_to_continue() {
614 let yaml = r#"
615version: 1
616pipeline:
617 source: { type: rest, config: {} }
618 sink: { type: jsonl, config: { path: ./o.jsonl } }
619execution: { max_concurrent: 2 }
620"#;
621 let cfg = parse_with_extension(yaml, "yaml").unwrap();
622 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
623 }
624
625 #[test]
626 fn rejects_old_top_level_source_sink_with_hint() {
627 let yaml = r#"
629version: 1
630source: { type: rest, config: {} }
631sink: { type: jsonl, config: { path: ./o.jsonl } }
632"#;
633 let err = parse_with_extension(yaml, "yaml").unwrap_err();
634 let msg = err.to_string();
635 assert!(
636 msg.contains("pipeline"),
637 "expected a hint about wrapping in `pipeline:`, got: {msg}"
638 );
639 }
640
641 #[test]
642 fn rejects_unknown_extension() {
643 let text = "version: 1\n";
644 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
645 assert!(matches!(err, CliError::UnknownExtension { .. }));
646 }
647
648 #[test]
649 fn rejects_future_version() {
650 let yaml = r#"
651version: 99
652pipeline:
653 source: { type: rest, config: {} }
654 sink: { type: jsonl, config: { path: ./x } }
655"#;
656 let err = parse_with_extension(yaml, "yaml").unwrap_err();
657 match err {
658 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
659 other => panic!("expected ParseConfig, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn transforms_and_state_round_trip() {
665 let yaml = r#"
666version: 1
667pipeline:
668 source:
669 type: rest
670 config: {}
671 transforms:
672 - type: snake_case
673 - type: flatten
674 config: { separator: "__" }
675 sink:
676 type: jsonl
677 config: { path: "./out.jsonl" }
678 state:
679 type: file
680 config: { path: "./.faucet-state" }
681"#;
682 let cfg = parse_with_extension(yaml, "yaml").unwrap();
683 assert_eq!(cfg.pipeline.transforms.len(), 2);
684 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
685 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
686 assert_eq!(
687 cfg.pipeline.transforms[1].config,
688 json!({"separator": "__"})
689 );
690 let state = cfg.pipeline.state.unwrap();
691 assert_eq!(state.kind, "file");
692 }
693
694 #[test]
695 fn from_path_interpolates_env_var() {
696 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
697 let dir = tempfile::tempdir().unwrap();
698 let path = dir.path().join("pipeline.yaml");
699 std::fs::write(
700 &path,
701 r#"
702version: 1
703pipeline:
704 source:
705 type: rest
706 config:
707 base_url: ${env:FAUCET_CFG_URL}
708 sink:
709 type: jsonl
710 config:
711 path: ./out.jsonl
712"#,
713 )
714 .unwrap();
715 let cfg = PipelineConfig::from_path(&path, None).unwrap();
716 assert_eq!(
717 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
718 "https://x.example"
719 );
720 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
721 }
722
723 #[test]
724 fn observability_block_parses() {
725 let y = r#"
726version: 1
727name: x
728observability:
729 prometheus:
730 listen: "127.0.0.1:9464"
731 buckets: [0.01, 0.1, 1.0]
732 tracing:
733 level: "info"
734pipeline:
735 source:
736 type: rest
737 config:
738 base_url: "https://example.com"
739 path: "/data"
740 sink:
741 type: jsonl
742 config:
743 path: "/tmp/faucet-test.jsonl"
744"#;
745 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
746 let obs = cfg.observability.expect("observability block parsed");
747 let p = obs.prometheus.expect("prometheus parsed");
748 assert_eq!(p.listen, "127.0.0.1:9464");
749 assert_eq!(p.buckets.unwrap().len(), 3);
750 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
751 }
752
753 #[test]
754 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
755 let dir = tempfile::tempdir().unwrap();
758 let path = dir.path().join("pipeline.yaml");
759 std::fs::write(
760 &path,
761 r#"
762version: 1
763pipeline:
764 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
765 sink: { type: jsonl, config: { path: ./o.jsonl } }
766"#,
767 )
768 .unwrap();
769 let cfg = PipelineConfig::from_path(&path, None).unwrap();
770 assert_eq!(
771 cfg.pipeline.source.as_ref().unwrap().config["path"],
772 "/v1/users/${users.id}/posts"
773 );
774 }
775
776 #[cfg(feature = "schedule")]
777 #[test]
778 fn parses_schedule_block() {
779 let yaml = r#"
780version: 1
781schedule:
782 cron: "0 2 * * *"
783 timezone: "America/Los_Angeles"
784 overlap_policy: skip
785 max_consecutive_failures: 5
786pipeline:
787 source: { type: rest, config: {} }
788 sink: { type: jsonl, config: { path: ./o.jsonl } }
789"#;
790 let cfg = parse_with_extension(yaml, "yaml").unwrap();
791 let s = cfg.schedule.expect("schedule parsed");
792 assert_eq!(s.cron, "0 2 * * *");
793 assert_eq!(s.timezone, "America/Los_Angeles");
794 assert_eq!(s.max_consecutive_failures, Some(5));
795 }
796
797 #[test]
798 fn execution_spec_parses_adaptive_block() {
799 let yaml = r#"
800version: 1
801pipeline:
802 source: { type: rest, config: { base_url: https://api.example.com } }
803 sink: { type: jsonl, config: { path: ./out.jsonl } }
804execution:
805 adaptive_batch_size:
806 enabled: true
807 min: 200
808 max: 4000
809 target_latency_ms: 800
810"#;
811 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
812 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
813 assert!(ab.enabled);
814 assert_eq!(ab.min, 200);
815 assert_eq!(ab.target_latency_ms, Some(800));
816 ab.validate().unwrap();
817 }
818
819 #[cfg(feature = "quality")]
820 #[test]
821 fn parses_quality_block() {
822 let yaml = r#"
823version: 1
824pipeline:
825 source: { type: rest, config: { url: "https://x" } }
826 quality:
827 record:
828 - { type: not_null, field: id, on_failure: abort }
829 sink: { type: stdout, config: {} }
830"#;
831 let cfg = parse_with_extension(yaml, "yaml").unwrap();
832 let q = cfg.pipeline.quality.expect("quality parsed");
833 assert_eq!(q.record.len(), 1);
834 }
835
836 #[test]
837 fn parses_dlq_block_with_defaults() {
838 let yaml = r#"
839version: 1
840pipeline:
841 source: { type: rest, config: {} }
842 sink: { type: jsonl, config: { path: ./o.jsonl } }
843 dlq:
844 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
845"#;
846 let cfg = parse_with_extension(yaml, "yaml").unwrap();
847 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
848 assert_eq!(dlq.sink.kind, "jsonl");
849 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
850 assert!(dlq.max_failures_per_page.is_none());
851 assert!(dlq.max_failures_total.is_none());
852 assert!(dlq.include_original_payload);
853 }
854
855 #[test]
856 fn parses_dlq_block_with_dlq_all_and_budgets() {
857 let yaml = r#"
858version: 1
859pipeline:
860 source: { type: rest, config: {} }
861 sink: { type: jsonl, config: { path: ./o.jsonl } }
862 dlq:
863 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
864 on_batch_error: dlq_all
865 max_failures_per_page: 100
866 max_failures_total: 10000
867"#;
868 let cfg = parse_with_extension(yaml, "yaml").unwrap();
869 let dlq = cfg.pipeline.dlq.unwrap();
870 assert_eq!(dlq.sink.kind, "kafka");
871 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
872 assert_eq!(dlq.max_failures_per_page, Some(100));
873 assert_eq!(dlq.max_failures_total, Some(10000));
874 }
875
876 #[test]
877 fn matrix_row_dlq_null_disables_inherited_dlq() {
878 let yaml = r#"
879version: 1
880pipeline:
881 source: { type: rest, config: {} }
882 sink: { type: jsonl, config: { path: ./o.jsonl } }
883 dlq:
884 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
885matrix:
886 - id: a
887 - id: b
888 dlq: null
889"#;
890 let cfg = parse_with_extension(yaml, "yaml").unwrap();
891 assert!(cfg.matrix[0].dlq.is_none());
892 assert_eq!(cfg.matrix[1].dlq, Some(None));
893 }
894
895 #[test]
896 fn matrix_row_dlq_object_replaces_inherited_dlq() {
897 let yaml = r#"
898version: 1
899pipeline:
900 source: { type: rest, config: {} }
901 sink: { type: jsonl, config: { path: ./o.jsonl } }
902 dlq:
903 sink: { type: jsonl, config: { path: ./base.jsonl } }
904matrix:
905 - id: a
906 dlq:
907 sink: { type: jsonl, config: { path: ./a.jsonl } }
908 on_batch_error: dlq_all
909"#;
910 let cfg = parse_with_extension(yaml, "yaml").unwrap();
911 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
912 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
913 let sink_path = row_dlq.sink.config.get("path").unwrap();
914 assert_eq!(sink_path, "./a.jsonl");
915 }
916
917 #[test]
918 fn parses_named_sources_and_sinks() {
919 let yaml = r#"
920version: 1
921pipeline:
922 sources:
923 users_api:
924 type: rest
925 config: { base_url: https://api.example.com }
926 posts_api:
927 type: rest
928 config: { base_url: https://api.example.com }
929 sinks:
930 warehouse:
931 type: postgres
932 config: { connection_url: "postgres://x" }
933"#;
934 let cfg = parse_with_extension(yaml, "yaml").unwrap();
935 assert!(cfg.pipeline.source.is_none());
936 assert!(cfg.pipeline.sink.is_none());
937 assert_eq!(cfg.pipeline.sources.len(), 2);
938 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
939 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
940 }
941
942 #[test]
943 fn legacy_singular_source_still_parses() {
944 let yaml = r#"
945version: 1
946pipeline:
947 source: { type: rest, config: {} }
948 sink: { type: jsonl, config: { path: ./o.jsonl } }
949"#;
950 let cfg = parse_with_extension(yaml, "yaml").unwrap();
951 assert!(cfg.pipeline.source.is_some());
952 assert!(cfg.pipeline.sink.is_some());
953 assert!(cfg.pipeline.sources.is_empty());
954 assert!(cfg.pipeline.sinks.is_empty());
955 }
956
957 #[test]
958 fn parses_matrix_row_with_ref_field() {
959 let yaml = r#"
960version: 1
961pipeline:
962 source: { type: rest, config: {} }
963 sink: { type: jsonl, config: { path: ./o.jsonl } }
964matrix:
965 - id: load_users
966 source:
967 ref: users_api
968 config: { path: /v1/users }
969"#;
970 let cfg = parse_with_extension(yaml, "yaml").unwrap();
971 let src = cfg.matrix[0].source.as_ref().unwrap();
972 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
973 assert_eq!(src.kind, None);
974 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
975 }
976
977 #[test]
978 fn parses_top_level_vars_block() {
979 let yaml = r#"
980version: 1
981vars:
982 api_base: https://api.example.com
983 api_token_env: API_TOKEN
984pipeline:
985 source: { type: rest, config: {} }
986 sink: { type: jsonl, config: { path: ./o.jsonl } }
987"#;
988 let cfg = parse_with_extension(yaml, "yaml").unwrap();
989 let vars = cfg.vars.as_ref().unwrap();
990 assert_eq!(vars["api_base"], "https://api.example.com");
991 assert_eq!(vars["api_token_env"], "API_TOKEN");
992 }
993
994 #[test]
995 fn vars_block_is_optional() {
996 let yaml = r#"
997version: 1
998pipeline:
999 source: { type: rest, config: {} }
1000 sink: { type: jsonl, config: { path: ./o.jsonl } }
1001"#;
1002 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1003 assert!(cfg.vars.is_none());
1004 }
1005
1006 #[test]
1007 fn from_path_resolves_vars_at_load() {
1008 let dir = tempfile::tempdir().unwrap();
1009 let path = dir.path().join("pipeline.yaml");
1010 std::fs::write(
1011 &path,
1012 r#"
1013version: 1
1014vars:
1015 base: https://api.example.com
1016pipeline:
1017 source: { type: rest, config: { url: "${vars.base}/v1" } }
1018 sink: { type: jsonl, config: { path: ./o.jsonl } }
1019"#,
1020 )
1021 .unwrap();
1022 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1023 assert_eq!(
1024 cfg.pipeline.source.as_ref().unwrap().config["url"],
1025 "https://api.example.com/v1"
1026 );
1027 }
1028
1029 #[test]
1030 fn sync_from_path_errors_on_secret_directive() {
1031 let dir = tempfile::tempdir().unwrap();
1032 let path = dir.path().join("p.yaml");
1033 std::fs::write(
1034 &path,
1035 r#"
1036version: 1
1037pipeline:
1038 source: { type: rest, config: { url: "${vault:secret/x}" } }
1039 sink: { type: jsonl, config: { path: ./o.jsonl } }
1040"#,
1041 )
1042 .unwrap();
1043 match PipelineConfig::from_path(&path, None).unwrap_err() {
1044 CliError::SecretsRequireAsyncLoad => {}
1045 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1046 }
1047 }
1048
1049 #[test]
1050 fn from_value_accepts_v1_and_resolves_refs() {
1051 let v = serde_json::json!({
1052 "version": 1,
1053 "vars": { "out": "resolved.jsonl" },
1054 "pipeline": {
1055 "source": { "type": "csv", "config": { "path": "x.csv" } },
1056 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1057 }
1058 });
1059 let cfg = PipelineConfig::from_value(v).unwrap();
1060 assert_eq!(cfg.version, 1);
1061 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1063 }
1064
1065 #[test]
1066 fn from_value_rejects_non_v1() {
1067 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1069 let err = PipelineConfig::from_value(v).unwrap_err();
1070 match err {
1071 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1072 other => panic!("expected ParseConfig, got {other:?}"),
1073 }
1074 }
1075
1076 #[tokio::test]
1077 async fn async_from_path_loads_without_secrets() {
1078 let dir = tempfile::tempdir().unwrap();
1079 let path = dir.path().join("p.yaml");
1080 std::fs::write(
1081 &path,
1082 r#"
1083version: 1
1084pipeline:
1085 source: { type: rest, config: { base_url: https://x } }
1086 sink: { type: jsonl, config: { path: ./o.jsonl } }
1087"#,
1088 )
1089 .unwrap();
1090 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1091 assert_eq!(cfg.version, 1);
1092 }
1093
1094 #[cfg(feature = "lineage")]
1095 #[test]
1096 fn parses_lineage_block() {
1097 let yaml = r#"
1098version: 1
1099lineage:
1100 namespace: prod
1101 transport: { type: file, config: { path: /tmp/ol.jsonl } }
1102pipeline:
1103 source: { type: rest, config: {} }
1104 sink: { type: jsonl, config: { path: ./o.jsonl } }
1105"#;
1106 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1107 let l = cfg.lineage.expect("lineage parsed");
1108 assert_eq!(l.namespace, "prod");
1109 }
1110
1111 #[test]
1112 fn from_path_resolves_extends_and_profile() {
1113 let dir = tempfile::tempdir().unwrap();
1114 std::fs::write(
1115 dir.path().join("base.yaml"),
1116 "version: 1\npipeline:\n source: { type: csv, config: { path: x.csv } }\n sink: { type: jsonl, config: { path: base.jsonl } }\nprofiles:\n prod:\n pipeline:\n sink: { config: { path: prod.jsonl } }\n",
1117 )
1118 .unwrap();
1119 let app = dir.path().join("app.yaml");
1120 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1121
1122 let cfg = PipelineConfig::from_path(&app, None).unwrap();
1124 assert_eq!(
1125 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1126 "base.jsonl"
1127 );
1128
1129 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1131 assert_eq!(
1132 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1133 "prod.jsonl"
1134 );
1135 }
1136
1137 #[test]
1138 fn from_value_rejects_extends_with_composition_hint() {
1139 let v = serde_json::json!({
1141 "version": 1,
1142 "extends": "base.yaml",
1143 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1144 });
1145 let err = PipelineConfig::from_value(v).unwrap_err();
1146 let msg = err.to_string();
1147 assert!(
1148 msg.contains("composition"),
1149 "expected composition hint, got: {msg}"
1150 );
1151 }
1152
1153 #[test]
1154 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1155 let yaml = r#"
1157version: 1
1158pipeline:
1159 source: { type: rest, config: {} }
1160 sink: { type: jsonl, config: { path: ./o.jsonl } }
1161"#;
1162 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1163 assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1164
1165 let yaml2 = r#"
1167version: 1
1168delivery: exactly_once
1169pipeline:
1170 source: { type: rest, config: {} }
1171 sink: { type: jsonl, config: { path: ./o.jsonl } }
1172"#;
1173 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
1174 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
1175
1176 let yaml3 = r#"
1178version: 1
1179delivery: at_least_once
1180pipeline:
1181 source: { type: rest, config: {} }
1182 sink: { type: jsonl, config: { path: ./o.jsonl } }
1183matrix:
1184 - id: a
1185 - id: b
1186 delivery: exactly_once
1187"#;
1188 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
1189 assert_eq!(cfg3.matrix[0].delivery, None);
1190 assert_eq!(
1191 cfg3.matrix[1].delivery,
1192 Some(faucet_core::DeliveryMode::ExactlyOnce)
1193 );
1194 }
1195}