1use crate::error::{CliError, CliResult};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct PipelineConfig {
42 #[serde(default = "default_version")]
44 pub version: u32,
45
46 #[serde(default)]
48 pub name: Option<String>,
49
50 #[serde(default)]
54 pub vars: Option<HashMap<String, Value>>,
55
56 #[serde(default)]
61 pub auth: Option<HashMap<String, Value>>,
62
63 pub pipeline: PipelineSpec,
65
66 #[serde(default)]
69 pub matrix: Vec<MatrixRow>,
70
71 #[serde(default)]
73 pub execution: Option<ExecutionSpec>,
74
75 #[serde(default)]
77 pub observability: Option<ObservabilitySpec>,
78
79 #[serde(default)]
84 pub delivery: faucet_core::DeliveryMode,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub resilience: Option<ResilienceSpec>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub sla: Option<crate::sla::SlaSpec>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub shard: Option<ShardingSpec>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub replication: Option<crate::replication::spec::ReplicationSpec>,
113
114 #[cfg(feature = "schedule")]
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
119
120 #[cfg(feature = "lineage")]
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub lineage: Option<faucet_lineage::LineageConfig>,
124
125 #[cfg(feature = "catalog")]
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub catalog: Option<crate::catalog::CatalogSpec>,
133
134 #[cfg(feature = "notify")]
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub notifications: Vec<crate::notify::NotificationSpec>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct PipelineSpec {
150 #[serde(default)]
153 pub source: Option<ConnectorSpec>,
154
155 #[serde(default)]
157 pub sink: Option<ConnectorSpec>,
158
159 #[serde(default)]
161 pub sources: HashMap<String, ConnectorSpec>,
162
163 #[serde(default)]
165 pub sinks: HashMap<String, ConnectorSpec>,
166
167 #[serde(default)]
168 pub transforms: Vec<TransformSpec>,
169 #[serde(default)]
170 pub state: Option<StateStoreSpec>,
171 #[serde(default)]
172 pub dlq: Option<DlqSpec>,
173
174 #[cfg(feature = "quality")]
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub quality: Option<faucet_core::QualitySpec>,
178
179 #[cfg(feature = "contract")]
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub contract: Option<faucet_core::ContractSpec>,
185
186 #[cfg(feature = "masking")]
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub masking: Option<faucet_core::MaskingSpec>,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub schema: Option<faucet_core::SchemaDriftSpec>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
206#[serde(deny_unknown_fields)]
207pub struct ConnectorSpec {
208 #[serde(rename = "type")]
211 pub kind: String,
212
213 #[serde(default = "empty_object")]
216 pub config: Value,
217
218 #[serde(default)]
222 pub transforms: Option<Vec<TransformSpec>>,
223
224 #[serde(default = "default_true")]
228 pub inherit_transforms: bool,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
238#[serde(deny_unknown_fields)]
239pub struct PartialConnector {
240 #[serde(default)]
243 pub r#ref: Option<String>,
244 #[serde(rename = "type", default)]
246 pub kind: Option<String>,
247 #[serde(default)]
249 pub config: Option<Value>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
254#[serde(deny_unknown_fields)]
255pub struct TransformSpec {
256 #[serde(rename = "type")]
261 pub kind: String,
262
263 #[serde(default = "empty_object")]
265 pub config: Value,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(deny_unknown_fields)]
271pub struct StateStoreSpec {
272 #[serde(rename = "type")]
274 pub kind: String,
275
276 #[serde(default = "empty_object")]
278 pub config: Value,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize)]
283#[serde(deny_unknown_fields)]
284pub struct MatrixRow {
285 #[serde(default)]
288 pub id: Option<String>,
289
290 #[serde(default)]
292 pub parent: Option<String>,
293
294 #[serde(default)]
299 pub depends_on: Vec<String>,
300
301 #[serde(default = "default_parent_key")]
304 pub parent_key: String,
305
306 #[serde(default)]
308 pub source: Option<PartialConnector>,
309
310 #[serde(default)]
312 pub sink: Option<PartialConnector>,
313
314 #[serde(default)]
319 pub transforms: Option<Vec<TransformSpec>>,
320
321 #[serde(default = "default_true")]
324 pub inherit_transforms: bool,
325
326 #[serde(default)]
328 pub state: Option<StateStoreSpec>,
329
330 #[serde(default, deserialize_with = "deserialize_dlq_override")]
335 pub dlq: Option<Option<DlqSpec>>,
336
337 #[serde(default)]
339 pub delivery: Option<faucet_core::DeliveryMode>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
344#[serde(deny_unknown_fields)]
345pub struct ExecutionSpec {
346 #[serde(default)]
350 pub max_concurrent: Option<usize>,
351
352 #[serde(default)]
354 pub on_error: OnError,
355
356 #[serde(default)]
358 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363#[serde(deny_unknown_fields)]
364pub struct ShardingSpec {
365 pub count: usize,
370}
371
372#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
374#[serde(rename_all = "lowercase")]
375pub enum OnError {
376 #[default]
378 Continue,
379 Stop,
381}
382
383#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
385pub struct ObservabilitySpec {
386 #[serde(default)]
388 pub prometheus: Option<PrometheusSpec>,
389
390 #[serde(default)]
392 pub tracing: Option<TracingSpec>,
393
394 #[serde(default)]
396 pub otel: Option<OtelSpec>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
401pub struct PrometheusSpec {
402 pub listen: String,
404
405 #[serde(default)]
408 pub buckets: Option<Vec<f64>>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
413pub struct TracingSpec {
414 #[serde(default)]
417 pub level: Option<String>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
422pub struct OtelSpec {
423 #[serde(default)]
426 pub endpoint: String,
427 #[serde(default)]
429 pub protocol: faucet_core::OtelProtocol,
430 #[serde(default)]
432 pub headers: std::collections::HashMap<String, String>,
433 #[serde(default = "default_otel_ratio")]
435 pub sample_ratio: f64,
436 #[serde(default = "default_otel_export")]
438 pub export: Vec<faucet_core::OtelSignal>,
439 #[serde(default = "default_otel_service")]
441 pub service_name: String,
442 #[serde(default = "default_otel_timeout")]
444 pub timeout_secs: u64,
445 #[serde(default = "default_otel_interval")]
447 pub metric_interval_secs: u64,
448}
449
450fn default_otel_ratio() -> f64 {
451 1.0
452}
453fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
454 vec![
455 faucet_core::OtelSignal::Traces,
456 faucet_core::OtelSignal::Metrics,
457 ]
458}
459fn default_otel_service() -> String {
460 "faucet".to_string()
461}
462fn default_otel_timeout() -> u64 {
463 10
464}
465fn default_otel_interval() -> u64 {
466 60
467}
468
469impl OtelSpec {
470 pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
472 let cfg = faucet_core::OtelConfig {
473 endpoint: self.endpoint.clone(),
474 protocol: self.protocol,
475 headers: self.headers.clone(),
476 sample_ratio: self.sample_ratio,
477 export: self.export.clone(),
478 service_name: self.service_name.clone(),
479 timeout_secs: self.timeout_secs,
480 metric_interval_secs: self.metric_interval_secs,
481 };
482 cfg.validate()?;
483 Ok(cfg)
484 }
485}
486
487#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
491#[serde(rename_all = "snake_case")]
492pub enum OnBatchErrorSpec {
493 #[default]
494 Propagate,
495 DlqAll,
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
500#[serde(deny_unknown_fields)]
501pub struct DlqSpec {
502 pub sink: ConnectorSpec,
503 #[serde(default)]
504 pub on_batch_error: OnBatchErrorSpec,
505 #[serde(default)]
506 pub max_failures_per_page: Option<usize>,
507 #[serde(default)]
508 pub max_failures_total: Option<usize>,
509 #[serde(default = "default_true")]
510 pub include_original_payload: bool,
511}
512
513#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
515#[serde(deny_unknown_fields)]
516pub struct ResilienceSpec {
517 #[serde(default)]
520 pub retry: RetrySpec,
521 #[serde(default)]
523 pub retry_on: Option<Vec<faucet_core::RetryClass>>,
524 #[serde(default)]
526 pub circuit_breaker: Option<CircuitBreakerSpec>,
527 #[serde(default)]
529 pub poison: Option<PoisonSpec>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
534#[serde(deny_unknown_fields)]
535pub struct RetrySpec {
536 #[serde(default = "default_max_attempts")]
538 pub max_attempts: u32,
539 #[serde(default)]
541 pub backoff: BackoffSpec,
542 #[serde(default = "default_base_ms")]
544 pub base_ms: u64,
545 #[serde(default = "default_max_ms")]
547 pub max_ms: u64,
548 #[serde(default = "default_true")]
550 pub jitter: bool,
551}
552
553impl Default for RetrySpec {
554 fn default() -> Self {
555 Self {
556 max_attempts: default_max_attempts(),
557 backoff: BackoffSpec::default(),
558 base_ms: default_base_ms(),
559 max_ms: default_max_ms(),
560 jitter: true,
561 }
562 }
563}
564
565#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
567#[serde(rename_all = "snake_case")]
568pub enum BackoffSpec {
569 None,
571 Fixed,
573 #[default]
575 Exponential,
576}
577
578#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
580#[serde(deny_unknown_fields)]
581pub struct CircuitBreakerSpec {
582 pub consecutive_failures: u32,
584 pub cooldown_secs: u64,
586}
587
588#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
590#[serde(deny_unknown_fields)]
591pub struct PoisonSpec {
592 pub max_row_attempts: u32,
594 #[serde(default)]
596 pub action: PoisonActionSpec,
597}
598
599#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
602#[serde(rename_all = "snake_case")]
603pub enum PoisonActionSpec {
604 #[default]
606 Dlq,
607 Drop,
609 Fail,
611}
612
613fn default_max_attempts() -> u32 {
614 5
615}
616fn default_base_ms() -> u64 {
617 200
618}
619fn default_max_ms() -> u64 {
620 30_000
621}
622
623impl ResilienceSpec {
624 pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
626 use crate::error::CliError;
627 if self.retry.max_attempts < 1 {
628 return Err(CliError::Config(
629 "resilience.retry.max_attempts must be >= 1".into(),
630 ));
631 }
632 if self.retry.base_ms > self.retry.max_ms {
633 return Err(CliError::Config(
634 "resilience.retry.base_ms must be <= max_ms".into(),
635 ));
636 }
637 let retry_on = match &self.retry_on {
638 Some(v) if v.is_empty() => {
639 return Err(CliError::Config(
640 "resilience.retry_on must not be empty".into(),
641 ));
642 }
643 Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
644 None => faucet_core::RetryClassSet::default(),
645 };
646 let backoff = match self.retry.backoff {
647 BackoffSpec::None => faucet_core::BackoffKind::None,
648 BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
649 BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
650 };
651 let circuit_breaker = match self.circuit_breaker {
652 Some(cb) if cb.consecutive_failures < 1 => {
653 return Err(CliError::Config(
654 "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
655 ));
656 }
657 Some(cb) => Some(faucet_core::CircuitBreakerConfig {
658 consecutive_failures: cb.consecutive_failures,
659 cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
660 }),
661 None => None,
662 };
663 let poison = match self.poison {
664 Some(p) if p.max_row_attempts < 1 => {
665 return Err(CliError::Config(
666 "resilience.poison.max_row_attempts must be >= 1".into(),
667 ));
668 }
669 Some(p) => Some(faucet_core::PoisonPolicy {
670 max_row_attempts: p.max_row_attempts,
671 action: match p.action {
672 PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
673 PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
674 PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
675 },
676 }),
677 None => None,
678 };
679 Ok(faucet_core::ResiliencePolicy {
680 retry: faucet_core::RetryPolicy {
681 max_attempts: self.retry.max_attempts,
682 backoff,
683 base: std::time::Duration::from_millis(self.retry.base_ms),
684 max: std::time::Duration::from_millis(self.retry.max_ms),
685 jitter: self.retry.jitter,
686 retry_on,
687 },
688 circuit_breaker,
689 poison,
690 })
691 }
692}
693
694fn default_true() -> bool {
695 true
696}
697
698fn default_version() -> u32 {
699 1
700}
701fn default_parent_key() -> String {
702 "id".to_owned()
703}
704fn empty_object() -> Value {
705 Value::Object(Default::default())
706}
707
708fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
709where
710 D: serde::Deserializer<'de>,
711{
712 Option::<DlqSpec>::deserialize(deserializer).map(Some)
713}
714
715fn interpolate_document(text: &str, path: &Path) -> CliResult<String> {
729 use crate::interpolate::interpolate_value;
730 let ext = path
731 .extension()
732 .and_then(|e| e.to_str())
733 .map(str::to_ascii_lowercase);
734 match ext.as_deref() {
735 Some("yaml" | "yml") => {
736 let mut value: serde_json::Value =
737 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
738 path: path.to_path_buf(),
739 message: friendly_parse_error(&e.to_string()),
740 })?;
741 interpolate_value(&mut value)?;
742 serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
743 path: path.to_path_buf(),
744 message: e.to_string(),
745 })
746 }
747 Some("json") => {
748 let mut value: serde_json::Value =
749 serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
750 path: path.to_path_buf(),
751 message: friendly_parse_error(&e.to_string()),
752 })?;
753 interpolate_value(&mut value)?;
754 serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
755 path: path.to_path_buf(),
756 message: e.to_string(),
757 })
758 }
759 _ => Err(CliError::UnknownExtension {
760 path: path.to_path_buf(),
761 }),
762 }
763}
764
765impl PipelineConfig {
766 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
779 let path = path.as_ref();
780 let composed = crate::compose::compose(path, profile)?;
781 let interpolated = interpolate_document(&composed, path)?;
782 let cfg = Self::from_text(&interpolated, path)?;
783 crate::secrets::ensure_no_secret_directives(&cfg)?;
786 Ok(cfg)
787 }
788
789 pub fn from_path_tolerating_secrets(
793 path: impl AsRef<Path>,
794 profile: Option<&str>,
795 ) -> CliResult<Self> {
796 let path = path.as_ref();
797 let composed = crate::compose::compose(path, profile)?;
798 let interpolated = interpolate_document(&composed, path)?;
799 Self::from_text(&interpolated, path)
800 }
801
802 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
806 let path = path.as_ref();
807 let composed = crate::compose::compose(path, profile)?;
808 let interpolated = interpolate_document(&composed, path)?;
809 let mut cfg = Self::from_text(&interpolated, path)?;
810 crate::secrets::resolve_secrets(&mut cfg).await?;
811 Ok(cfg)
812 }
813
814 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
817 let ext = path
818 .extension()
819 .and_then(|e| e.to_str())
820 .map(str::to_ascii_lowercase);
821 let cfg: PipelineConfig = match ext.as_deref() {
822 Some("yaml" | "yml") => {
823 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
824 path: path.to_path_buf(),
825 message: friendly_parse_error(&e.to_string()),
826 })?
827 }
828 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
829 path: path.to_path_buf(),
830 message: friendly_parse_error(&e.to_string()),
831 })?,
832 _ => {
833 return Err(CliError::UnknownExtension {
834 path: path.to_path_buf(),
835 });
836 }
837 };
838 Self::finish(cfg, path)
839 }
840
841 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
849 let synthetic = Path::new("<submitted>");
850 let cfg: PipelineConfig =
851 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
852 path: synthetic.to_path_buf(),
853 message: friendly_parse_error(&e.to_string()),
854 })?;
855 Self::finish(cfg, synthetic)
856 }
857
858 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
860 if cfg.version != 1 {
861 return Err(CliError::ParseConfig {
862 path: path.to_path_buf(),
863 message: format!(
864 "unsupported pipeline version {}, only version 1 is recognised",
865 cfg.version
866 ),
867 });
868 }
869 crate::interpolate::resolve_config_refs(&mut cfg)?;
870 if let Some(obs) = cfg.observability.as_ref()
871 && let Some(otel) = obs.otel.as_ref()
872 {
873 otel.to_core().map_err(CliError::Config)?;
874 }
875 Ok(cfg)
876 }
877}
878
879fn friendly_parse_error(raw: &str) -> String {
882 let lower = raw.to_ascii_lowercase();
883 if lower.contains("missing field `pipeline`") {
884 return format!(
885 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
886 );
887 }
888 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
889 return format!(
890 "{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."
891 );
892 }
893 raw.to_owned()
894}
895
896pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
899 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
900 PipelineConfig::from_text(text, &synthetic)
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use serde_json::json;
907
908 #[test]
909 fn parses_minimal_pipeline_yaml() {
910 let yaml = r#"
911version: 1
912pipeline:
913 source:
914 type: rest
915 config:
916 base_url: https://api.example.com
917 sink:
918 type: jsonl
919 config:
920 path: ./out.jsonl
921"#;
922 let cfg = parse_with_extension(yaml, "yaml").unwrap();
923 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
924 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
925 assert!(cfg.matrix.is_empty());
926 assert!(cfg.execution.is_none());
927 assert!(cfg.pipeline.transforms.is_empty());
928 assert!(cfg.pipeline.state.is_none());
929 }
930
931 #[test]
932 fn parses_replication_block() {
933 let yaml = r#"
934version: 1
935pipeline:
936 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
937 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
938 state: { type: file, config: { path: ./st } }
939replication:
940 mode: snapshot_then_cdc
941 snapshot:
942 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
943"#;
944 let cfg = parse_with_extension(yaml, "yaml").unwrap();
945 let r = cfg.replication.expect("replication parsed");
946 assert_eq!(r.snapshot.source.kind, "postgres");
947 }
948
949 #[test]
950 fn pipeline_spec_parses_schema_block() {
951 let yaml = r#"
952version: 1
953pipeline:
954 source:
955 type: rest
956 config:
957 base_url: https://api.example.com
958 sink:
959 type: jsonl
960 config:
961 path: ./out.jsonl
962 schema:
963 on_drift: evolve
964 allow_type_widening: false
965"#;
966 let cfg = parse_with_extension(yaml, "yaml").unwrap();
967 let schema = cfg.pipeline.schema.expect("schema block parsed");
968 assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
969 assert!(!schema.allow_type_widening);
970 }
971
972 #[test]
973 fn parses_minimal_json() {
974 let raw = r#"{
975 "version": 1,
976 "pipeline": {
977 "source": {"type": "rest", "config": {}},
978 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
979 }
980 }"#;
981 let cfg = parse_with_extension(raw, "json").unwrap();
982 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
983 }
984
985 #[test]
986 fn parses_matrix_rows_with_partial_overrides() {
987 let yaml = r#"
988version: 1
989pipeline:
990 source: { type: rest, config: { base_url: https://api.example.com } }
991 sink: { type: jsonl, config: { path: ./out.jsonl } }
992matrix:
993 - id: users
994 source: { config: { path: /v1/users } }
995 sink: { config: { path: ./users.jsonl } }
996 - id: posts
997 parent: users
998 parent_key: user_id
999 source: { config: { path: "/v1/users/${users.id}/posts" } }
1000"#;
1001 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1002 assert_eq!(cfg.matrix.len(), 2);
1003 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
1004 assert!(cfg.matrix[0].parent.is_none());
1005 let users_src = cfg.matrix[0].source.as_ref().unwrap();
1006 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
1007
1008 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
1009 assert_eq!(cfg.matrix[1].parent_key, "user_id");
1010 }
1011
1012 #[test]
1013 fn parent_key_defaults_to_id() {
1014 let yaml = r#"
1015version: 1
1016pipeline:
1017 source: { type: rest, config: {} }
1018 sink: { type: jsonl, config: { path: ./o.jsonl } }
1019matrix:
1020 - { id: users }
1021 - { id: posts, parent: users }
1022"#;
1023 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1024 assert_eq!(cfg.matrix[1].parent_key, "id");
1025 }
1026
1027 #[test]
1028 fn parses_execution_block() {
1029 let yaml = r#"
1030version: 1
1031pipeline:
1032 source: { type: rest, config: {} }
1033 sink: { type: jsonl, config: { path: ./o.jsonl } }
1034execution:
1035 max_concurrent: 8
1036 on_error: stop
1037"#;
1038 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1039 let exec = cfg.execution.unwrap();
1040 assert_eq!(exec.max_concurrent, Some(8));
1041 assert_eq!(exec.on_error, OnError::Stop);
1042 }
1043
1044 #[test]
1045 fn on_error_defaults_to_continue() {
1046 let yaml = r#"
1047version: 1
1048pipeline:
1049 source: { type: rest, config: {} }
1050 sink: { type: jsonl, config: { path: ./o.jsonl } }
1051execution: { max_concurrent: 2 }
1052"#;
1053 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1054 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1055 }
1056
1057 #[test]
1058 fn rejects_old_top_level_source_sink_with_hint() {
1059 let yaml = r#"
1061version: 1
1062source: { type: rest, config: {} }
1063sink: { type: jsonl, config: { path: ./o.jsonl } }
1064"#;
1065 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1066 let msg = err.to_string();
1067 assert!(
1068 msg.contains("pipeline"),
1069 "expected a hint about wrapping in `pipeline:`, got: {msg}"
1070 );
1071 }
1072
1073 #[test]
1074 fn rejects_unknown_extension() {
1075 let text = "version: 1\n";
1076 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1077 assert!(matches!(err, CliError::UnknownExtension { .. }));
1078 }
1079
1080 #[test]
1081 fn rejects_future_version() {
1082 let yaml = r#"
1083version: 99
1084pipeline:
1085 source: { type: rest, config: {} }
1086 sink: { type: jsonl, config: { path: ./x } }
1087"#;
1088 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1089 match err {
1090 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1091 other => panic!("expected ParseConfig, got {other:?}"),
1092 }
1093 }
1094
1095 #[test]
1096 fn transforms_and_state_round_trip() {
1097 let yaml = r#"
1098version: 1
1099pipeline:
1100 source:
1101 type: rest
1102 config: {}
1103 transforms:
1104 - type: snake_case
1105 - type: flatten
1106 config: { separator: "__" }
1107 sink:
1108 type: jsonl
1109 config: { path: "./out.jsonl" }
1110 state:
1111 type: file
1112 config: { path: "./.faucet-state" }
1113"#;
1114 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1115 assert_eq!(cfg.pipeline.transforms.len(), 2);
1116 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1117 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1118 assert_eq!(
1119 cfg.pipeline.transforms[1].config,
1120 json!({"separator": "__"})
1121 );
1122 let state = cfg.pipeline.state.unwrap();
1123 assert_eq!(state.kind, "file");
1124 }
1125
1126 #[test]
1127 fn from_path_interpolates_env_var() {
1128 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1129 let dir = tempfile::tempdir().unwrap();
1130 let path = dir.path().join("pipeline.yaml");
1131 std::fs::write(
1132 &path,
1133 r#"
1134version: 1
1135pipeline:
1136 source:
1137 type: rest
1138 config:
1139 base_url: ${env:FAUCET_CFG_URL}
1140 sink:
1141 type: jsonl
1142 config:
1143 path: ./out.jsonl
1144"#,
1145 )
1146 .unwrap();
1147 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1148 assert_eq!(
1149 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1150 "https://x.example"
1151 );
1152 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1153 }
1154
1155 #[test]
1156 fn observability_block_parses() {
1157 let y = r#"
1158version: 1
1159name: x
1160observability:
1161 prometheus:
1162 listen: "127.0.0.1:9464"
1163 buckets: [0.01, 0.1, 1.0]
1164 tracing:
1165 level: "info"
1166pipeline:
1167 source:
1168 type: rest
1169 config:
1170 base_url: "https://example.com"
1171 path: "/data"
1172 sink:
1173 type: jsonl
1174 config:
1175 path: "/tmp/faucet-test.jsonl"
1176"#;
1177 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1178 let obs = cfg.observability.expect("observability block parsed");
1179 let p = obs.prometheus.expect("prometheus parsed");
1180 assert_eq!(p.listen, "127.0.0.1:9464");
1181 assert_eq!(p.buckets.unwrap().len(), 3);
1182 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1183 }
1184
1185 #[test]
1186 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1187 let dir = tempfile::tempdir().unwrap();
1190 let path = dir.path().join("pipeline.yaml");
1191 std::fs::write(
1192 &path,
1193 r#"
1194version: 1
1195pipeline:
1196 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1197 sink: { type: jsonl, config: { path: ./o.jsonl } }
1198"#,
1199 )
1200 .unwrap();
1201 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1202 assert_eq!(
1203 cfg.pipeline.source.as_ref().unwrap().config["path"],
1204 "/v1/users/${users.id}/posts"
1205 );
1206 }
1207
1208 #[cfg(feature = "schedule")]
1209 #[test]
1210 fn parses_schedule_block() {
1211 let yaml = r#"
1212version: 1
1213schedule:
1214 cron: "0 2 * * *"
1215 timezone: "America/Los_Angeles"
1216 overlap_policy: skip
1217 max_consecutive_failures: 5
1218pipeline:
1219 source: { type: rest, config: {} }
1220 sink: { type: jsonl, config: { path: ./o.jsonl } }
1221"#;
1222 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1223 let s = cfg.schedule.expect("schedule parsed");
1224 assert_eq!(s.cron, "0 2 * * *");
1225 assert_eq!(s.timezone, "America/Los_Angeles");
1226 assert_eq!(s.max_consecutive_failures, Some(5));
1227 }
1228
1229 #[test]
1230 fn execution_spec_parses_adaptive_block() {
1231 let yaml = r#"
1232version: 1
1233pipeline:
1234 source: { type: rest, config: { base_url: https://api.example.com } }
1235 sink: { type: jsonl, config: { path: ./out.jsonl } }
1236execution:
1237 adaptive_batch_size:
1238 enabled: true
1239 min: 200
1240 max: 4000
1241 target_latency_ms: 800
1242"#;
1243 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1244 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1245 assert!(ab.enabled);
1246 assert_eq!(ab.min, 200);
1247 assert_eq!(ab.target_latency_ms, Some(800));
1248 ab.validate().unwrap();
1249 }
1250
1251 #[cfg(feature = "quality")]
1252 #[test]
1253 fn parses_quality_block() {
1254 let yaml = r#"
1255version: 1
1256pipeline:
1257 source: { type: rest, config: { url: "https://x" } }
1258 quality:
1259 record:
1260 - { type: not_null, field: id, on_failure: abort }
1261 sink: { type: stdout, config: {} }
1262"#;
1263 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1264 let q = cfg.pipeline.quality.expect("quality parsed");
1265 assert_eq!(q.record.len(), 1);
1266 }
1267
1268 #[cfg(feature = "contract")]
1269 #[test]
1270 fn parses_contract_block() {
1271 let yaml = r#"
1272version: 1
1273pipeline:
1274 source: { type: rest, config: { url: "https://x" } }
1275 contract:
1276 version: "1.0.0"
1277 on_breach: warn
1278 fields:
1279 - { name: id, type: integer }
1280 - { name: status, type: string, enum: [open, closed] }
1281 sink: { type: stdout, config: {} }
1282"#;
1283 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1284 let c = cfg.pipeline.contract.expect("contract parsed");
1285 assert_eq!(c.version, "1.0.0");
1286 assert_eq!(c.on_breach, faucet_core::OnBreach::Warn);
1287 assert_eq!(c.fields.len(), 2);
1288 }
1289
1290 #[test]
1291 fn parses_dlq_block_with_defaults() {
1292 let yaml = r#"
1293version: 1
1294pipeline:
1295 source: { type: rest, config: {} }
1296 sink: { type: jsonl, config: { path: ./o.jsonl } }
1297 dlq:
1298 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1299"#;
1300 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1301 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1302 assert_eq!(dlq.sink.kind, "jsonl");
1303 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1304 assert!(dlq.max_failures_per_page.is_none());
1305 assert!(dlq.max_failures_total.is_none());
1306 assert!(dlq.include_original_payload);
1307 }
1308
1309 #[test]
1310 fn parses_dlq_block_with_dlq_all_and_budgets() {
1311 let yaml = r#"
1312version: 1
1313pipeline:
1314 source: { type: rest, config: {} }
1315 sink: { type: jsonl, config: { path: ./o.jsonl } }
1316 dlq:
1317 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1318 on_batch_error: dlq_all
1319 max_failures_per_page: 100
1320 max_failures_total: 10000
1321"#;
1322 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1323 let dlq = cfg.pipeline.dlq.unwrap();
1324 assert_eq!(dlq.sink.kind, "kafka");
1325 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1326 assert_eq!(dlq.max_failures_per_page, Some(100));
1327 assert_eq!(dlq.max_failures_total, Some(10000));
1328 }
1329
1330 #[test]
1331 fn matrix_row_dlq_null_disables_inherited_dlq() {
1332 let yaml = r#"
1333version: 1
1334pipeline:
1335 source: { type: rest, config: {} }
1336 sink: { type: jsonl, config: { path: ./o.jsonl } }
1337 dlq:
1338 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1339matrix:
1340 - id: a
1341 - id: b
1342 dlq: null
1343"#;
1344 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1345 assert!(cfg.matrix[0].dlq.is_none());
1346 assert_eq!(cfg.matrix[1].dlq, Some(None));
1347 }
1348
1349 #[test]
1350 fn matrix_row_dlq_object_replaces_inherited_dlq() {
1351 let yaml = r#"
1352version: 1
1353pipeline:
1354 source: { type: rest, config: {} }
1355 sink: { type: jsonl, config: { path: ./o.jsonl } }
1356 dlq:
1357 sink: { type: jsonl, config: { path: ./base.jsonl } }
1358matrix:
1359 - id: a
1360 dlq:
1361 sink: { type: jsonl, config: { path: ./a.jsonl } }
1362 on_batch_error: dlq_all
1363"#;
1364 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1365 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1366 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1367 let sink_path = row_dlq.sink.config.get("path").unwrap();
1368 assert_eq!(sink_path, "./a.jsonl");
1369 }
1370
1371 #[test]
1372 fn parses_named_sources_and_sinks() {
1373 let yaml = r#"
1374version: 1
1375pipeline:
1376 sources:
1377 users_api:
1378 type: rest
1379 config: { base_url: https://api.example.com }
1380 posts_api:
1381 type: rest
1382 config: { base_url: https://api.example.com }
1383 sinks:
1384 warehouse:
1385 type: postgres
1386 config: { connection_url: "postgres://x" }
1387"#;
1388 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1389 assert!(cfg.pipeline.source.is_none());
1390 assert!(cfg.pipeline.sink.is_none());
1391 assert_eq!(cfg.pipeline.sources.len(), 2);
1392 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1393 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1394 }
1395
1396 #[test]
1397 fn legacy_singular_source_still_parses() {
1398 let yaml = r#"
1399version: 1
1400pipeline:
1401 source: { type: rest, config: {} }
1402 sink: { type: jsonl, config: { path: ./o.jsonl } }
1403"#;
1404 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1405 assert!(cfg.pipeline.source.is_some());
1406 assert!(cfg.pipeline.sink.is_some());
1407 assert!(cfg.pipeline.sources.is_empty());
1408 assert!(cfg.pipeline.sinks.is_empty());
1409 }
1410
1411 #[test]
1412 fn parses_matrix_row_with_ref_field() {
1413 let yaml = r#"
1414version: 1
1415pipeline:
1416 source: { type: rest, config: {} }
1417 sink: { type: jsonl, config: { path: ./o.jsonl } }
1418matrix:
1419 - id: load_users
1420 source:
1421 ref: users_api
1422 config: { path: /v1/users }
1423"#;
1424 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1425 let src = cfg.matrix[0].source.as_ref().unwrap();
1426 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1427 assert_eq!(src.kind, None);
1428 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1429 }
1430
1431 #[test]
1432 fn parses_top_level_vars_block() {
1433 let yaml = r#"
1434version: 1
1435vars:
1436 api_base: https://api.example.com
1437 api_token_env: API_TOKEN
1438pipeline:
1439 source: { type: rest, config: {} }
1440 sink: { type: jsonl, config: { path: ./o.jsonl } }
1441"#;
1442 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1443 let vars = cfg.vars.as_ref().unwrap();
1444 assert_eq!(vars["api_base"], "https://api.example.com");
1445 assert_eq!(vars["api_token_env"], "API_TOKEN");
1446 }
1447
1448 #[test]
1449 fn vars_block_is_optional() {
1450 let yaml = r#"
1451version: 1
1452pipeline:
1453 source: { type: rest, config: {} }
1454 sink: { type: jsonl, config: { path: ./o.jsonl } }
1455"#;
1456 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1457 assert!(cfg.vars.is_none());
1458 }
1459
1460 #[test]
1461 fn from_path_resolves_vars_at_load() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let path = dir.path().join("pipeline.yaml");
1464 std::fs::write(
1465 &path,
1466 r#"
1467version: 1
1468vars:
1469 base: https://api.example.com
1470pipeline:
1471 source: { type: rest, config: { url: "${vars.base}/v1" } }
1472 sink: { type: jsonl, config: { path: ./o.jsonl } }
1473"#,
1474 )
1475 .unwrap();
1476 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1477 assert_eq!(
1478 cfg.pipeline.source.as_ref().unwrap().config["url"],
1479 "https://api.example.com/v1"
1480 );
1481 }
1482
1483 #[test]
1484 fn sync_from_path_errors_on_secret_directive() {
1485 let dir = tempfile::tempdir().unwrap();
1486 let path = dir.path().join("p.yaml");
1487 std::fs::write(
1488 &path,
1489 r#"
1490version: 1
1491pipeline:
1492 source: { type: rest, config: { url: "${vault:secret/x}" } }
1493 sink: { type: jsonl, config: { path: ./o.jsonl } }
1494"#,
1495 )
1496 .unwrap();
1497 match PipelineConfig::from_path(&path, None).unwrap_err() {
1498 CliError::SecretsRequireAsyncLoad => {}
1499 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1500 }
1501 }
1502
1503 #[test]
1504 fn from_value_accepts_v1_and_resolves_refs() {
1505 let v = serde_json::json!({
1506 "version": 1,
1507 "vars": { "out": "resolved.jsonl" },
1508 "pipeline": {
1509 "source": { "type": "csv", "config": { "path": "x.csv" } },
1510 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1511 }
1512 });
1513 let cfg = PipelineConfig::from_value(v).unwrap();
1514 assert_eq!(cfg.version, 1);
1515 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1517 }
1518
1519 #[test]
1520 fn from_value_rejects_non_v1() {
1521 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1523 let err = PipelineConfig::from_value(v).unwrap_err();
1524 match err {
1525 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1526 other => panic!("expected ParseConfig, got {other:?}"),
1527 }
1528 }
1529
1530 #[tokio::test]
1531 async fn async_from_path_loads_without_secrets() {
1532 let dir = tempfile::tempdir().unwrap();
1533 let path = dir.path().join("p.yaml");
1534 std::fs::write(
1535 &path,
1536 r#"
1537version: 1
1538pipeline:
1539 source: { type: rest, config: { base_url: https://x } }
1540 sink: { type: jsonl, config: { path: ./o.jsonl } }
1541"#,
1542 )
1543 .unwrap();
1544 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1545 assert_eq!(cfg.version, 1);
1546 }
1547
1548 #[cfg(feature = "lineage")]
1549 #[test]
1550 fn parses_lineage_block() {
1551 let yaml = r#"
1552version: 1
1553lineage:
1554 namespace: prod
1555 transport: { type: file, config: { path: /tmp/ol.jsonl } }
1556pipeline:
1557 source: { type: rest, config: {} }
1558 sink: { type: jsonl, config: { path: ./o.jsonl } }
1559"#;
1560 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1561 let l = cfg.lineage.expect("lineage parsed");
1562 assert_eq!(l.namespace, "prod");
1563 }
1564
1565 #[test]
1566 fn from_path_resolves_extends_and_profile() {
1567 let dir = tempfile::tempdir().unwrap();
1568 std::fs::write(
1569 dir.path().join("base.yaml"),
1570 "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",
1571 )
1572 .unwrap();
1573 let app = dir.path().join("app.yaml");
1574 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1575
1576 let cfg = PipelineConfig::from_path(&app, None).unwrap();
1578 assert_eq!(
1579 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1580 "base.jsonl"
1581 );
1582
1583 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1585 assert_eq!(
1586 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1587 "prod.jsonl"
1588 );
1589 }
1590
1591 #[test]
1592 fn from_value_rejects_extends_with_composition_hint() {
1593 let v = serde_json::json!({
1595 "version": 1,
1596 "extends": "base.yaml",
1597 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1598 });
1599 let err = PipelineConfig::from_value(v).unwrap_err();
1600 let msg = err.to_string();
1601 assert!(
1602 msg.contains("composition"),
1603 "expected composition hint, got: {msg}"
1604 );
1605 }
1606
1607 #[test]
1608 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1609 let yaml = r#"
1611version: 1
1612pipeline:
1613 source: { type: rest, config: {} }
1614 sink: { type: jsonl, config: { path: ./o.jsonl } }
1615"#;
1616 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1617 assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1618
1619 let yaml2 = r#"
1621version: 1
1622delivery: exactly_once
1623pipeline:
1624 source: { type: rest, config: {} }
1625 sink: { type: jsonl, config: { path: ./o.jsonl } }
1626"#;
1627 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
1628 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
1629
1630 let yaml3 = r#"
1632version: 1
1633delivery: at_least_once
1634pipeline:
1635 source: { type: rest, config: {} }
1636 sink: { type: jsonl, config: { path: ./o.jsonl } }
1637matrix:
1638 - id: a
1639 - id: b
1640 delivery: exactly_once
1641"#;
1642 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
1643 assert_eq!(cfg3.matrix[0].delivery, None);
1644 assert_eq!(
1645 cfg3.matrix[1].delivery,
1646 Some(faucet_core::DeliveryMode::ExactlyOnce)
1647 );
1648 }
1649
1650 #[test]
1651 fn resilience_spec_parses_and_builds_policy() {
1652 let yaml = r#"
1653version: 1
1654pipeline:
1655 source: { type: rest, config: { base_url: "https://x" } }
1656 sink: { type: stdout, config: {} }
1657resilience:
1658 retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
1659 retry_on: [http_5xx, timeout]
1660 circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
1661 poison: { max_row_attempts: 2, action: dlq }
1662"#;
1663 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1664 let spec = cfg.resilience.unwrap();
1665 let policy = spec.to_policy().unwrap();
1666 assert_eq!(policy.retry.max_attempts, 4);
1667 assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
1668 assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
1669 }
1670
1671 #[test]
1672 fn resilience_rejects_zero_max_attempts() {
1673 let yaml = r#"
1674version: 1
1675pipeline:
1676 source: { type: rest, config: { base_url: "https://x" } }
1677 sink: { type: stdout, config: {} }
1678resilience: { retry: { max_attempts: 0 } }
1679"#;
1680 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1681 let err = cfg.resilience.unwrap().to_policy().unwrap_err();
1682 assert!(err.to_string().contains("max_attempts"));
1683 }
1684
1685 #[test]
1686 fn observability_parses_otel_block() {
1687 let yaml = r#"
1688version: 1
1689pipeline:
1690 source: { type: rest, config: { base_url: "http://x" } }
1691 sink: { type: stdout, config: {} }
1692observability:
1693 otel:
1694 endpoint: http://collector:4317
1695 protocol: grpc
1696 export: [traces, metrics]
1697"#;
1698 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1699 let otel = cfg.observability.unwrap().otel.unwrap();
1700 assert_eq!(otel.endpoint, "http://collector:4317");
1701 }
1702
1703 #[test]
1704 fn otel_validation_rejects_bad_ratio() {
1705 let yaml = r#"
1706version: 1
1707pipeline:
1708 source: { type: rest, config: { base_url: "http://x" } }
1709 sink: { type: stdout, config: {} }
1710observability:
1711 otel:
1712 sample_ratio: 9.0
1713"#;
1714 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1715 assert!(format!("{err}").contains("sample_ratio"));
1716 }
1717}