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 #[cfg(feature = "schedule")]
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct PipelineSpec {
93 #[serde(default)]
96 pub source: Option<ConnectorSpec>,
97
98 #[serde(default)]
100 pub sink: Option<ConnectorSpec>,
101
102 #[serde(default)]
104 pub sources: HashMap<String, ConnectorSpec>,
105
106 #[serde(default)]
108 pub sinks: HashMap<String, ConnectorSpec>,
109
110 #[serde(default)]
111 pub transforms: Vec<TransformSpec>,
112 #[serde(default)]
113 pub state: Option<StateStoreSpec>,
114 #[serde(default)]
115 pub dlq: Option<DlqSpec>,
116
117 #[cfg(feature = "quality")]
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub quality: Option<faucet_core::QualitySpec>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
128#[serde(deny_unknown_fields)]
129pub struct ConnectorSpec {
130 #[serde(rename = "type")]
133 pub kind: String,
134
135 #[serde(default = "empty_object")]
138 pub config: Value,
139
140 #[serde(default)]
144 pub transforms: Option<Vec<TransformSpec>>,
145
146 #[serde(default = "default_true")]
150 pub inherit_transforms: bool,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct PartialConnector {
162 #[serde(default)]
165 pub r#ref: Option<String>,
166 #[serde(rename = "type", default)]
168 pub kind: Option<String>,
169 #[serde(default)]
171 pub config: Option<Value>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
176#[serde(deny_unknown_fields)]
177pub struct TransformSpec {
178 #[serde(rename = "type")]
183 pub kind: String,
184
185 #[serde(default = "empty_object")]
187 pub config: Value,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192#[serde(deny_unknown_fields)]
193pub struct StateStoreSpec {
194 #[serde(rename = "type")]
196 pub kind: String,
197
198 #[serde(default = "empty_object")]
200 pub config: Value,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub struct MatrixRow {
207 #[serde(default)]
210 pub id: Option<String>,
211
212 #[serde(default)]
214 pub parent: Option<String>,
215
216 #[serde(default = "default_parent_key")]
219 pub parent_key: String,
220
221 #[serde(default)]
223 pub source: Option<PartialConnector>,
224
225 #[serde(default)]
227 pub sink: Option<PartialConnector>,
228
229 #[serde(default)]
234 pub transforms: Option<Vec<TransformSpec>>,
235
236 #[serde(default = "default_true")]
239 pub inherit_transforms: bool,
240
241 #[serde(default)]
243 pub state: Option<StateStoreSpec>,
244
245 #[serde(default, deserialize_with = "deserialize_dlq_override")]
250 pub dlq: Option<Option<DlqSpec>>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(deny_unknown_fields)]
256pub struct ExecutionSpec {
257 #[serde(default)]
261 pub max_concurrent: Option<usize>,
262
263 #[serde(default)]
265 pub on_error: OnError,
266
267 #[serde(default)]
269 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
270}
271
272#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
274#[serde(rename_all = "lowercase")]
275pub enum OnError {
276 #[default]
278 Continue,
279 Stop,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
285pub struct ObservabilitySpec {
286 #[serde(default)]
288 pub prometheus: Option<PrometheusSpec>,
289
290 #[serde(default)]
292 pub tracing: Option<TracingSpec>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297pub struct PrometheusSpec {
298 pub listen: String,
300
301 #[serde(default)]
304 pub buckets: Option<Vec<f64>>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
309pub struct TracingSpec {
310 #[serde(default)]
313 pub level: Option<String>,
314}
315
316#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
320#[serde(rename_all = "snake_case")]
321pub enum OnBatchErrorSpec {
322 #[default]
323 Propagate,
324 DlqAll,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
329#[serde(deny_unknown_fields)]
330pub struct DlqSpec {
331 pub sink: ConnectorSpec,
332 #[serde(default)]
333 pub on_batch_error: OnBatchErrorSpec,
334 #[serde(default)]
335 pub max_failures_per_page: Option<usize>,
336 #[serde(default)]
337 pub max_failures_total: Option<usize>,
338 #[serde(default = "default_true")]
339 pub include_original_payload: bool,
340}
341
342fn default_true() -> bool {
343 true
344}
345
346fn default_version() -> u32 {
347 1
348}
349fn default_parent_key() -> String {
350 "id".to_owned()
351}
352fn empty_object() -> Value {
353 Value::Object(Default::default())
354}
355
356fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
357where
358 D: serde::Deserializer<'de>,
359{
360 Option::<DlqSpec>::deserialize(deserializer).map(Some)
361}
362
363impl PipelineConfig {
364 pub fn from_path(path: impl AsRef<Path>) -> CliResult<Self> {
372 let path = path.as_ref();
373 let raw = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
374 path: path.to_path_buf(),
375 source,
376 })?;
377 let interpolated = interpolate(&raw)?;
378 let cfg = Self::from_text(&interpolated, path)?;
379 crate::secrets::ensure_no_secret_directives(&cfg)?;
382 Ok(cfg)
383 }
384
385 pub fn from_path_tolerating_secrets(path: impl AsRef<Path>) -> CliResult<Self> {
388 let path = path.as_ref();
389 let raw = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
390 path: path.to_path_buf(),
391 source,
392 })?;
393 let interpolated = interpolate(&raw)?;
394 Self::from_text(&interpolated, path)
395 }
396
397 pub async fn from_path_async(path: impl AsRef<Path>) -> CliResult<Self> {
400 let path = path.as_ref();
401 let raw = std::fs::read_to_string(path).map_err(|source| CliError::ReadConfig {
402 path: path.to_path_buf(),
403 source,
404 })?;
405 let interpolated = interpolate(&raw)?;
406 let mut cfg = Self::from_text(&interpolated, path)?;
407 crate::secrets::resolve_secrets(&mut cfg).await?;
408 Ok(cfg)
409 }
410
411 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
414 let ext = path
415 .extension()
416 .and_then(|e| e.to_str())
417 .map(str::to_ascii_lowercase);
418 let cfg: PipelineConfig = match ext.as_deref() {
419 Some("yaml" | "yml") => {
420 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
421 path: path.to_path_buf(),
422 message: friendly_parse_error(&e.to_string()),
423 })?
424 }
425 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
426 path: path.to_path_buf(),
427 message: friendly_parse_error(&e.to_string()),
428 })?,
429 _ => {
430 return Err(CliError::UnknownExtension {
431 path: path.to_path_buf(),
432 });
433 }
434 };
435 Self::finish(cfg, path)
436 }
437
438 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
446 let synthetic = Path::new("<submitted>");
447 let cfg: PipelineConfig =
448 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
449 path: synthetic.to_path_buf(),
450 message: friendly_parse_error(&e.to_string()),
451 })?;
452 Self::finish(cfg, synthetic)
453 }
454
455 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
457 if cfg.version != 1 {
458 return Err(CliError::ParseConfig {
459 path: path.to_path_buf(),
460 message: format!(
461 "unsupported pipeline version {}, only version 1 is recognised",
462 cfg.version
463 ),
464 });
465 }
466 crate::interpolate::resolve_config_refs(&mut cfg)?;
467 Ok(cfg)
468 }
469}
470
471fn friendly_parse_error(raw: &str) -> String {
474 let lower = raw.to_ascii_lowercase();
475 if lower.contains("missing field `pipeline`") {
476 return format!(
477 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
478 );
479 }
480 raw.to_owned()
481}
482
483pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
486 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
487 PipelineConfig::from_text(text, &synthetic)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use serde_json::json;
494
495 #[test]
496 fn parses_minimal_pipeline_yaml() {
497 let yaml = r#"
498version: 1
499pipeline:
500 source:
501 type: rest
502 config:
503 base_url: https://api.example.com
504 sink:
505 type: jsonl
506 config:
507 path: ./out.jsonl
508"#;
509 let cfg = parse_with_extension(yaml, "yaml").unwrap();
510 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
511 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
512 assert!(cfg.matrix.is_empty());
513 assert!(cfg.execution.is_none());
514 assert!(cfg.pipeline.transforms.is_empty());
515 assert!(cfg.pipeline.state.is_none());
516 }
517
518 #[test]
519 fn parses_minimal_json() {
520 let raw = r#"{
521 "version": 1,
522 "pipeline": {
523 "source": {"type": "rest", "config": {}},
524 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
525 }
526 }"#;
527 let cfg = parse_with_extension(raw, "json").unwrap();
528 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
529 }
530
531 #[test]
532 fn parses_matrix_rows_with_partial_overrides() {
533 let yaml = r#"
534version: 1
535pipeline:
536 source: { type: rest, config: { base_url: https://api.example.com } }
537 sink: { type: jsonl, config: { path: ./out.jsonl } }
538matrix:
539 - id: users
540 source: { config: { path: /v1/users } }
541 sink: { config: { path: ./users.jsonl } }
542 - id: posts
543 parent: users
544 parent_key: user_id
545 source: { config: { path: "/v1/users/${users.id}/posts" } }
546"#;
547 let cfg = parse_with_extension(yaml, "yaml").unwrap();
548 assert_eq!(cfg.matrix.len(), 2);
549 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
550 assert!(cfg.matrix[0].parent.is_none());
551 let users_src = cfg.matrix[0].source.as_ref().unwrap();
552 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
553
554 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
555 assert_eq!(cfg.matrix[1].parent_key, "user_id");
556 }
557
558 #[test]
559 fn parent_key_defaults_to_id() {
560 let yaml = r#"
561version: 1
562pipeline:
563 source: { type: rest, config: {} }
564 sink: { type: jsonl, config: { path: ./o.jsonl } }
565matrix:
566 - { id: users }
567 - { id: posts, parent: users }
568"#;
569 let cfg = parse_with_extension(yaml, "yaml").unwrap();
570 assert_eq!(cfg.matrix[1].parent_key, "id");
571 }
572
573 #[test]
574 fn parses_execution_block() {
575 let yaml = r#"
576version: 1
577pipeline:
578 source: { type: rest, config: {} }
579 sink: { type: jsonl, config: { path: ./o.jsonl } }
580execution:
581 max_concurrent: 8
582 on_error: stop
583"#;
584 let cfg = parse_with_extension(yaml, "yaml").unwrap();
585 let exec = cfg.execution.unwrap();
586 assert_eq!(exec.max_concurrent, Some(8));
587 assert_eq!(exec.on_error, OnError::Stop);
588 }
589
590 #[test]
591 fn on_error_defaults_to_continue() {
592 let yaml = r#"
593version: 1
594pipeline:
595 source: { type: rest, config: {} }
596 sink: { type: jsonl, config: { path: ./o.jsonl } }
597execution: { max_concurrent: 2 }
598"#;
599 let cfg = parse_with_extension(yaml, "yaml").unwrap();
600 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
601 }
602
603 #[test]
604 fn rejects_old_top_level_source_sink_with_hint() {
605 let yaml = r#"
607version: 1
608source: { type: rest, config: {} }
609sink: { type: jsonl, config: { path: ./o.jsonl } }
610"#;
611 let err = parse_with_extension(yaml, "yaml").unwrap_err();
612 let msg = err.to_string();
613 assert!(
614 msg.contains("pipeline"),
615 "expected a hint about wrapping in `pipeline:`, got: {msg}"
616 );
617 }
618
619 #[test]
620 fn rejects_unknown_extension() {
621 let text = "version: 1\n";
622 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
623 assert!(matches!(err, CliError::UnknownExtension { .. }));
624 }
625
626 #[test]
627 fn rejects_future_version() {
628 let yaml = r#"
629version: 99
630pipeline:
631 source: { type: rest, config: {} }
632 sink: { type: jsonl, config: { path: ./x } }
633"#;
634 let err = parse_with_extension(yaml, "yaml").unwrap_err();
635 match err {
636 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
637 other => panic!("expected ParseConfig, got {other:?}"),
638 }
639 }
640
641 #[test]
642 fn transforms_and_state_round_trip() {
643 let yaml = r#"
644version: 1
645pipeline:
646 source:
647 type: rest
648 config: {}
649 transforms:
650 - type: snake_case
651 - type: flatten
652 config: { separator: "__" }
653 sink:
654 type: jsonl
655 config: { path: "./out.jsonl" }
656 state:
657 type: file
658 config: { path: "./.faucet-state" }
659"#;
660 let cfg = parse_with_extension(yaml, "yaml").unwrap();
661 assert_eq!(cfg.pipeline.transforms.len(), 2);
662 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
663 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
664 assert_eq!(
665 cfg.pipeline.transforms[1].config,
666 json!({"separator": "__"})
667 );
668 let state = cfg.pipeline.state.unwrap();
669 assert_eq!(state.kind, "file");
670 }
671
672 #[test]
673 fn from_path_interpolates_env_var() {
674 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
675 let dir = tempfile::tempdir().unwrap();
676 let path = dir.path().join("pipeline.yaml");
677 std::fs::write(
678 &path,
679 r#"
680version: 1
681pipeline:
682 source:
683 type: rest
684 config:
685 base_url: ${env:FAUCET_CFG_URL}
686 sink:
687 type: jsonl
688 config:
689 path: ./out.jsonl
690"#,
691 )
692 .unwrap();
693 let cfg = PipelineConfig::from_path(&path).unwrap();
694 assert_eq!(
695 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
696 "https://x.example"
697 );
698 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
699 }
700
701 #[test]
702 fn observability_block_parses() {
703 let y = r#"
704version: 1
705name: x
706observability:
707 prometheus:
708 listen: "127.0.0.1:9464"
709 buckets: [0.01, 0.1, 1.0]
710 tracing:
711 level: "info"
712pipeline:
713 source:
714 type: rest
715 config:
716 base_url: "https://example.com"
717 path: "/data"
718 sink:
719 type: jsonl
720 config:
721 path: "/tmp/faucet-test.jsonl"
722"#;
723 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
724 let obs = cfg.observability.expect("observability block parsed");
725 let p = obs.prometheus.expect("prometheus parsed");
726 assert_eq!(p.listen, "127.0.0.1:9464");
727 assert_eq!(p.buckets.unwrap().len(), 3);
728 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
729 }
730
731 #[test]
732 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
733 let dir = tempfile::tempdir().unwrap();
736 let path = dir.path().join("pipeline.yaml");
737 std::fs::write(
738 &path,
739 r#"
740version: 1
741pipeline:
742 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
743 sink: { type: jsonl, config: { path: ./o.jsonl } }
744"#,
745 )
746 .unwrap();
747 let cfg = PipelineConfig::from_path(&path).unwrap();
748 assert_eq!(
749 cfg.pipeline.source.as_ref().unwrap().config["path"],
750 "/v1/users/${users.id}/posts"
751 );
752 }
753
754 #[cfg(feature = "schedule")]
755 #[test]
756 fn parses_schedule_block() {
757 let yaml = r#"
758version: 1
759schedule:
760 cron: "0 2 * * *"
761 timezone: "America/Los_Angeles"
762 overlap_policy: skip
763 max_consecutive_failures: 5
764pipeline:
765 source: { type: rest, config: {} }
766 sink: { type: jsonl, config: { path: ./o.jsonl } }
767"#;
768 let cfg = parse_with_extension(yaml, "yaml").unwrap();
769 let s = cfg.schedule.expect("schedule parsed");
770 assert_eq!(s.cron, "0 2 * * *");
771 assert_eq!(s.timezone, "America/Los_Angeles");
772 assert_eq!(s.max_consecutive_failures, Some(5));
773 }
774
775 #[test]
776 fn execution_spec_parses_adaptive_block() {
777 let yaml = r#"
778version: 1
779pipeline:
780 source: { type: rest, config: { base_url: https://api.example.com } }
781 sink: { type: jsonl, config: { path: ./out.jsonl } }
782execution:
783 adaptive_batch_size:
784 enabled: true
785 min: 200
786 max: 4000
787 target_latency_ms: 800
788"#;
789 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
790 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
791 assert!(ab.enabled);
792 assert_eq!(ab.min, 200);
793 assert_eq!(ab.target_latency_ms, Some(800));
794 ab.validate().unwrap();
795 }
796
797 #[cfg(feature = "quality")]
798 #[test]
799 fn parses_quality_block() {
800 let yaml = r#"
801version: 1
802pipeline:
803 source: { type: rest, config: { url: "https://x" } }
804 quality:
805 record:
806 - { type: not_null, field: id, on_failure: abort }
807 sink: { type: stdout, config: {} }
808"#;
809 let cfg = parse_with_extension(yaml, "yaml").unwrap();
810 let q = cfg.pipeline.quality.expect("quality parsed");
811 assert_eq!(q.record.len(), 1);
812 }
813
814 #[test]
815 fn parses_dlq_block_with_defaults() {
816 let yaml = r#"
817version: 1
818pipeline:
819 source: { type: rest, config: {} }
820 sink: { type: jsonl, config: { path: ./o.jsonl } }
821 dlq:
822 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
823"#;
824 let cfg = parse_with_extension(yaml, "yaml").unwrap();
825 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
826 assert_eq!(dlq.sink.kind, "jsonl");
827 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
828 assert!(dlq.max_failures_per_page.is_none());
829 assert!(dlq.max_failures_total.is_none());
830 assert!(dlq.include_original_payload);
831 }
832
833 #[test]
834 fn parses_dlq_block_with_dlq_all_and_budgets() {
835 let yaml = r#"
836version: 1
837pipeline:
838 source: { type: rest, config: {} }
839 sink: { type: jsonl, config: { path: ./o.jsonl } }
840 dlq:
841 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
842 on_batch_error: dlq_all
843 max_failures_per_page: 100
844 max_failures_total: 10000
845"#;
846 let cfg = parse_with_extension(yaml, "yaml").unwrap();
847 let dlq = cfg.pipeline.dlq.unwrap();
848 assert_eq!(dlq.sink.kind, "kafka");
849 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
850 assert_eq!(dlq.max_failures_per_page, Some(100));
851 assert_eq!(dlq.max_failures_total, Some(10000));
852 }
853
854 #[test]
855 fn matrix_row_dlq_null_disables_inherited_dlq() {
856 let yaml = r#"
857version: 1
858pipeline:
859 source: { type: rest, config: {} }
860 sink: { type: jsonl, config: { path: ./o.jsonl } }
861 dlq:
862 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
863matrix:
864 - id: a
865 - id: b
866 dlq: null
867"#;
868 let cfg = parse_with_extension(yaml, "yaml").unwrap();
869 assert!(cfg.matrix[0].dlq.is_none());
870 assert_eq!(cfg.matrix[1].dlq, Some(None));
871 }
872
873 #[test]
874 fn matrix_row_dlq_object_replaces_inherited_dlq() {
875 let yaml = r#"
876version: 1
877pipeline:
878 source: { type: rest, config: {} }
879 sink: { type: jsonl, config: { path: ./o.jsonl } }
880 dlq:
881 sink: { type: jsonl, config: { path: ./base.jsonl } }
882matrix:
883 - id: a
884 dlq:
885 sink: { type: jsonl, config: { path: ./a.jsonl } }
886 on_batch_error: dlq_all
887"#;
888 let cfg = parse_with_extension(yaml, "yaml").unwrap();
889 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
890 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
891 let sink_path = row_dlq.sink.config.get("path").unwrap();
892 assert_eq!(sink_path, "./a.jsonl");
893 }
894
895 #[test]
896 fn parses_named_sources_and_sinks() {
897 let yaml = r#"
898version: 1
899pipeline:
900 sources:
901 users_api:
902 type: rest
903 config: { base_url: https://api.example.com }
904 posts_api:
905 type: rest
906 config: { base_url: https://api.example.com }
907 sinks:
908 warehouse:
909 type: postgres
910 config: { connection_url: "postgres://x" }
911"#;
912 let cfg = parse_with_extension(yaml, "yaml").unwrap();
913 assert!(cfg.pipeline.source.is_none());
914 assert!(cfg.pipeline.sink.is_none());
915 assert_eq!(cfg.pipeline.sources.len(), 2);
916 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
917 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
918 }
919
920 #[test]
921 fn legacy_singular_source_still_parses() {
922 let yaml = r#"
923version: 1
924pipeline:
925 source: { type: rest, config: {} }
926 sink: { type: jsonl, config: { path: ./o.jsonl } }
927"#;
928 let cfg = parse_with_extension(yaml, "yaml").unwrap();
929 assert!(cfg.pipeline.source.is_some());
930 assert!(cfg.pipeline.sink.is_some());
931 assert!(cfg.pipeline.sources.is_empty());
932 assert!(cfg.pipeline.sinks.is_empty());
933 }
934
935 #[test]
936 fn parses_matrix_row_with_ref_field() {
937 let yaml = r#"
938version: 1
939pipeline:
940 source: { type: rest, config: {} }
941 sink: { type: jsonl, config: { path: ./o.jsonl } }
942matrix:
943 - id: load_users
944 source:
945 ref: users_api
946 config: { path: /v1/users }
947"#;
948 let cfg = parse_with_extension(yaml, "yaml").unwrap();
949 let src = cfg.matrix[0].source.as_ref().unwrap();
950 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
951 assert_eq!(src.kind, None);
952 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
953 }
954
955 #[test]
956 fn parses_top_level_vars_block() {
957 let yaml = r#"
958version: 1
959vars:
960 api_base: https://api.example.com
961 api_token_env: API_TOKEN
962pipeline:
963 source: { type: rest, config: {} }
964 sink: { type: jsonl, config: { path: ./o.jsonl } }
965"#;
966 let cfg = parse_with_extension(yaml, "yaml").unwrap();
967 let vars = cfg.vars.as_ref().unwrap();
968 assert_eq!(vars["api_base"], "https://api.example.com");
969 assert_eq!(vars["api_token_env"], "API_TOKEN");
970 }
971
972 #[test]
973 fn vars_block_is_optional() {
974 let yaml = r#"
975version: 1
976pipeline:
977 source: { type: rest, config: {} }
978 sink: { type: jsonl, config: { path: ./o.jsonl } }
979"#;
980 let cfg = parse_with_extension(yaml, "yaml").unwrap();
981 assert!(cfg.vars.is_none());
982 }
983
984 #[test]
985 fn from_path_resolves_vars_at_load() {
986 let dir = tempfile::tempdir().unwrap();
987 let path = dir.path().join("pipeline.yaml");
988 std::fs::write(
989 &path,
990 r#"
991version: 1
992vars:
993 base: https://api.example.com
994pipeline:
995 source: { type: rest, config: { url: "${vars.base}/v1" } }
996 sink: { type: jsonl, config: { path: ./o.jsonl } }
997"#,
998 )
999 .unwrap();
1000 let cfg = PipelineConfig::from_path(&path).unwrap();
1001 assert_eq!(
1002 cfg.pipeline.source.as_ref().unwrap().config["url"],
1003 "https://api.example.com/v1"
1004 );
1005 }
1006
1007 #[test]
1008 fn sync_from_path_errors_on_secret_directive() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let path = dir.path().join("p.yaml");
1011 std::fs::write(
1012 &path,
1013 r#"
1014version: 1
1015pipeline:
1016 source: { type: rest, config: { url: "${vault:secret/x}" } }
1017 sink: { type: jsonl, config: { path: ./o.jsonl } }
1018"#,
1019 )
1020 .unwrap();
1021 match PipelineConfig::from_path(&path).unwrap_err() {
1022 CliError::SecretsRequireAsyncLoad => {}
1023 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1024 }
1025 }
1026
1027 #[test]
1028 fn from_value_accepts_v1_and_resolves_refs() {
1029 let v = serde_json::json!({
1030 "version": 1,
1031 "vars": { "out": "resolved.jsonl" },
1032 "pipeline": {
1033 "source": { "type": "csv", "config": { "path": "x.csv" } },
1034 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1035 }
1036 });
1037 let cfg = PipelineConfig::from_value(v).unwrap();
1038 assert_eq!(cfg.version, 1);
1039 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1041 }
1042
1043 #[test]
1044 fn from_value_rejects_non_v1() {
1045 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1047 let err = PipelineConfig::from_value(v).unwrap_err();
1048 match err {
1049 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1050 other => panic!("expected ParseConfig, got {other:?}"),
1051 }
1052 }
1053
1054 #[tokio::test]
1055 async fn async_from_path_loads_without_secrets() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let path = dir.path().join("p.yaml");
1058 std::fs::write(
1059 &path,
1060 r#"
1061version: 1
1062pipeline:
1063 source: { type: rest, config: { base_url: https://x } }
1064 sink: { type: jsonl, config: { path: ./o.jsonl } }
1065"#,
1066 )
1067 .unwrap();
1068 let cfg = PipelineConfig::from_path_async(&path).await.unwrap();
1069 assert_eq!(cfg.version, 1);
1070 }
1071}