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