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, skip_serializing_if = "Option::is_none")]
80 pub selection: Option<SelectionSpec>,
81
82 #[serde(default)]
84 pub observability: Option<ObservabilitySpec>,
85
86 #[serde(default)]
91 pub delivery: faucet_core::DeliveryMode,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub resilience: Option<ResilienceSpec>,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub sla: Option<crate::sla::SlaSpec>,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub shard: Option<ShardingSpec>,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub replication: Option<crate::replication::spec::ReplicationSpec>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub backfill: Option<crate::backfill::BackfillSpec>,
126
127 #[cfg(feature = "schedule")]
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
132
133 #[cfg(feature = "lineage")]
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub lineage: Option<faucet_lineage::LineageConfig>,
137
138 #[cfg(feature = "catalog")]
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub catalog: Option<crate::catalog::CatalogSpec>,
146
147 #[cfg(feature = "notify")]
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub notifications: Vec<crate::notify::NotificationSpec>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct PipelineSpec {
163 #[serde(default)]
166 pub source: Option<ConnectorSpec>,
167
168 #[serde(default)]
170 pub sink: Option<ConnectorSpec>,
171
172 #[serde(default)]
174 pub sources: HashMap<String, ConnectorSpec>,
175
176 #[serde(default)]
178 pub sinks: HashMap<String, ConnectorSpec>,
179
180 #[serde(default)]
181 pub transforms: Vec<TransformSpec>,
182 #[serde(default)]
183 pub state: Option<StateStoreSpec>,
184 #[serde(default)]
185 pub dlq: Option<DlqSpec>,
186
187 #[cfg(feature = "quality")]
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub quality: Option<faucet_core::QualitySpec>,
191
192 #[cfg(feature = "contract")]
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub contract: Option<faucet_core::ContractSpec>,
198
199 #[cfg(feature = "masking")]
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub masking: Option<faucet_core::MaskingSpec>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub schema: Option<faucet_core::SchemaDriftSpec>,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
219#[serde(deny_unknown_fields)]
220pub struct ConnectorSpec {
221 #[serde(rename = "type")]
224 pub kind: String,
225
226 #[serde(default = "empty_object")]
229 pub config: Value,
230
231 #[serde(default)]
235 pub transforms: Option<Vec<TransformSpec>>,
236
237 #[serde(default = "default_true")]
241 pub inherit_transforms: bool,
242
243 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub status: Option<SourceStatus>,
249
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub tags: Vec<String>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
265#[serde(deny_unknown_fields)]
266pub struct PartialConnector {
267 #[serde(default)]
270 pub r#ref: Option<String>,
271 #[serde(rename = "type", default)]
273 pub kind: Option<String>,
274 #[serde(default)]
276 pub config: Option<Value>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub status: Option<SourceStatus>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
286#[serde(deny_unknown_fields)]
287pub struct TransformSpec {
288 #[serde(rename = "type")]
293 pub kind: String,
294
295 #[serde(default = "empty_object")]
297 pub config: Value,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
302#[serde(deny_unknown_fields)]
303pub struct StateStoreSpec {
304 #[serde(rename = "type")]
306 pub kind: String,
307
308 #[serde(default = "empty_object")]
310 pub config: Value,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315#[serde(deny_unknown_fields)]
316pub struct MatrixRow {
317 #[serde(default)]
320 pub id: Option<String>,
321
322 #[serde(default)]
324 pub parent: Option<String>,
325
326 #[serde(default)]
331 pub depends_on: Vec<String>,
332
333 #[serde(default = "default_parent_key")]
336 pub parent_key: String,
337
338 #[serde(default)]
340 pub source: Option<PartialConnector>,
341
342 #[serde(default)]
344 pub sink: Option<PartialConnector>,
345
346 #[serde(default)]
351 pub transforms: Option<Vec<TransformSpec>>,
352
353 #[serde(default = "default_true")]
356 pub inherit_transforms: bool,
357
358 #[serde(default)]
360 pub state: Option<StateStoreSpec>,
361
362 #[serde(default, deserialize_with = "deserialize_dlq_override")]
367 pub dlq: Option<Option<DlqSpec>>,
368
369 #[serde(default)]
371 pub delivery: Option<faucet_core::DeliveryMode>,
372
373 #[serde(default, skip_serializing_if = "Vec::is_empty")]
380 pub tags: Vec<String>,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
388#[serde(rename_all = "snake_case")]
389pub enum SourceStatus {
390 Mandatory,
392 #[default]
394 Active,
395 Available,
397 Draft,
399 Archived,
401}
402
403impl SourceStatus {
404 pub const ALL: [SourceStatus; 5] = [
406 SourceStatus::Mandatory,
407 SourceStatus::Active,
408 SourceStatus::Available,
409 SourceStatus::Draft,
410 SourceStatus::Archived,
411 ];
412
413 pub fn as_str(self) -> &'static str {
415 match self {
416 SourceStatus::Mandatory => "mandatory",
417 SourceStatus::Active => "active",
418 SourceStatus::Available => "available",
419 SourceStatus::Draft => "draft",
420 SourceStatus::Archived => "archived",
421 }
422 }
423
424 pub fn parse(s: &str) -> Option<Self> {
427 SourceStatus::ALL.into_iter().find(|v| v.as_str() == s)
428 }
429
430 pub fn default_eligible(self) -> bool {
432 matches!(self, SourceStatus::Mandatory | SourceStatus::Active)
433 }
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
442#[serde(rename_all = "snake_case")]
443pub enum IncludeParents {
444 #[default]
447 Off,
448 Eligible,
451 All,
454}
455
456impl IncludeParents {
457 pub fn as_str(self) -> &'static str {
459 match self {
460 IncludeParents::Off => "off",
461 IncludeParents::Eligible => "eligible",
462 IncludeParents::All => "all",
463 }
464 }
465
466 pub fn parse(s: &str) -> Option<Self> {
468 match s {
469 "off" => Some(IncludeParents::Off),
470 "eligible" => Some(IncludeParents::Eligible),
471 "all" => Some(IncludeParents::All),
472 _ => None,
473 }
474 }
475}
476
477#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
480#[serde(deny_unknown_fields)]
481pub struct SelectionSpec {
482 #[serde(default)]
484 pub include_parents: IncludeParents,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
489#[serde(deny_unknown_fields)]
490pub struct ExecutionSpec {
491 #[serde(default)]
495 pub max_concurrent: Option<usize>,
496
497 #[serde(default)]
499 pub on_error: OnError,
500
501 #[serde(default)]
503 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
508#[serde(deny_unknown_fields)]
509pub struct ShardingSpec {
510 pub count: usize,
515}
516
517#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
519#[serde(rename_all = "lowercase")]
520pub enum OnError {
521 #[default]
523 Continue,
524 Stop,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
530pub struct ObservabilitySpec {
531 #[serde(default)]
533 pub prometheus: Option<PrometheusSpec>,
534
535 #[serde(default)]
537 pub tracing: Option<TracingSpec>,
538
539 #[serde(default)]
541 pub otel: Option<OtelSpec>,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
546pub struct PrometheusSpec {
547 pub listen: String,
549
550 #[serde(default)]
553 pub buckets: Option<Vec<f64>>,
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
558pub struct TracingSpec {
559 #[serde(default)]
562 pub level: Option<String>,
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
567pub struct OtelSpec {
568 #[serde(default)]
571 pub endpoint: String,
572 #[serde(default)]
574 pub protocol: faucet_core::OtelProtocol,
575 #[serde(default)]
577 pub headers: std::collections::HashMap<String, String>,
578 #[serde(default = "default_otel_ratio")]
580 pub sample_ratio: f64,
581 #[serde(default = "default_otel_export")]
583 pub export: Vec<faucet_core::OtelSignal>,
584 #[serde(default = "default_otel_service")]
586 pub service_name: String,
587 #[serde(default = "default_otel_timeout")]
589 pub timeout_secs: u64,
590 #[serde(default = "default_otel_interval")]
592 pub metric_interval_secs: u64,
593}
594
595fn default_otel_ratio() -> f64 {
596 1.0
597}
598fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
599 vec![
600 faucet_core::OtelSignal::Traces,
601 faucet_core::OtelSignal::Metrics,
602 ]
603}
604fn default_otel_service() -> String {
605 "faucet".to_string()
606}
607fn default_otel_timeout() -> u64 {
608 10
609}
610fn default_otel_interval() -> u64 {
611 60
612}
613
614impl OtelSpec {
615 pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
617 let cfg = faucet_core::OtelConfig {
618 endpoint: self.endpoint.clone(),
619 protocol: self.protocol,
620 headers: self.headers.clone(),
621 sample_ratio: self.sample_ratio,
622 export: self.export.clone(),
623 service_name: self.service_name.clone(),
624 timeout_secs: self.timeout_secs,
625 metric_interval_secs: self.metric_interval_secs,
626 };
627 cfg.validate()?;
628 Ok(cfg)
629 }
630}
631
632#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
636#[serde(rename_all = "snake_case")]
637pub enum OnBatchErrorSpec {
638 #[default]
639 Propagate,
640 DlqAll,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
645#[serde(deny_unknown_fields)]
646pub struct DlqSpec {
647 pub sink: ConnectorSpec,
648 #[serde(default)]
649 pub on_batch_error: OnBatchErrorSpec,
650 #[serde(default)]
651 pub max_failures_per_page: Option<usize>,
652 #[serde(default)]
653 pub max_failures_total: Option<usize>,
654 #[serde(default = "default_true")]
655 pub include_original_payload: bool,
656}
657
658#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
660#[serde(deny_unknown_fields)]
661pub struct ResilienceSpec {
662 #[serde(default)]
665 pub retry: RetrySpec,
666 #[serde(default)]
668 pub retry_on: Option<Vec<faucet_core::RetryClass>>,
669 #[serde(default)]
671 pub circuit_breaker: Option<CircuitBreakerSpec>,
672 #[serde(default)]
674 pub poison: Option<PoisonSpec>,
675}
676
677#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
679#[serde(deny_unknown_fields)]
680pub struct RetrySpec {
681 #[serde(default = "default_max_attempts")]
683 pub max_attempts: u32,
684 #[serde(default)]
686 pub backoff: BackoffSpec,
687 #[serde(default = "default_base_ms")]
689 pub base_ms: u64,
690 #[serde(default = "default_max_ms")]
692 pub max_ms: u64,
693 #[serde(default = "default_true")]
695 pub jitter: bool,
696}
697
698impl Default for RetrySpec {
699 fn default() -> Self {
700 Self {
701 max_attempts: default_max_attempts(),
702 backoff: BackoffSpec::default(),
703 base_ms: default_base_ms(),
704 max_ms: default_max_ms(),
705 jitter: true,
706 }
707 }
708}
709
710#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
712#[serde(rename_all = "snake_case")]
713pub enum BackoffSpec {
714 None,
716 Fixed,
718 #[default]
720 Exponential,
721}
722
723#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
725#[serde(deny_unknown_fields)]
726pub struct CircuitBreakerSpec {
727 pub consecutive_failures: u32,
729 pub cooldown_secs: u64,
731}
732
733#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
735#[serde(deny_unknown_fields)]
736pub struct PoisonSpec {
737 pub max_row_attempts: u32,
739 #[serde(default)]
741 pub action: PoisonActionSpec,
742}
743
744#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
747#[serde(rename_all = "snake_case")]
748pub enum PoisonActionSpec {
749 #[default]
751 Dlq,
752 Drop,
754 Fail,
756}
757
758fn default_max_attempts() -> u32 {
759 5
760}
761fn default_base_ms() -> u64 {
762 200
763}
764fn default_max_ms() -> u64 {
765 30_000
766}
767
768impl ResilienceSpec {
769 pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
771 use crate::error::CliError;
772 if self.retry.max_attempts < 1 {
773 return Err(CliError::Config(
774 "resilience.retry.max_attempts must be >= 1".into(),
775 ));
776 }
777 if self.retry.base_ms > self.retry.max_ms {
778 return Err(CliError::Config(
779 "resilience.retry.base_ms must be <= max_ms".into(),
780 ));
781 }
782 let retry_on = match &self.retry_on {
783 Some(v) if v.is_empty() => {
784 return Err(CliError::Config(
785 "resilience.retry_on must not be empty".into(),
786 ));
787 }
788 Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
789 None => faucet_core::RetryClassSet::default(),
790 };
791 let backoff = match self.retry.backoff {
792 BackoffSpec::None => faucet_core::BackoffKind::None,
793 BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
794 BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
795 };
796 let circuit_breaker = match self.circuit_breaker {
797 Some(cb) if cb.consecutive_failures < 1 => {
798 return Err(CliError::Config(
799 "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
800 ));
801 }
802 Some(cb) => Some(faucet_core::CircuitBreakerConfig {
803 consecutive_failures: cb.consecutive_failures,
804 cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
805 }),
806 None => None,
807 };
808 let poison = match self.poison {
809 Some(p) if p.max_row_attempts < 1 => {
810 return Err(CliError::Config(
811 "resilience.poison.max_row_attempts must be >= 1".into(),
812 ));
813 }
814 Some(p) => Some(faucet_core::PoisonPolicy {
815 max_row_attempts: p.max_row_attempts,
816 action: match p.action {
817 PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
818 PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
819 PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
820 },
821 }),
822 None => None,
823 };
824 Ok(faucet_core::ResiliencePolicy {
825 retry: faucet_core::RetryPolicy {
826 max_attempts: self.retry.max_attempts,
827 backoff,
828 base: std::time::Duration::from_millis(self.retry.base_ms),
829 max: std::time::Duration::from_millis(self.retry.max_ms),
830 jitter: self.retry.jitter,
831 retry_on,
832 },
833 circuit_breaker,
834 poison,
835 })
836 }
837}
838
839fn default_true() -> bool {
840 true
841}
842
843fn default_version() -> u32 {
844 1
845}
846fn default_parent_key() -> String {
847 "id".to_owned()
848}
849fn empty_object() -> Value {
850 Value::Object(Default::default())
851}
852
853fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
854where
855 D: serde::Deserializer<'de>,
856{
857 Option::<DlqSpec>::deserialize(deserializer).map(Some)
858}
859
860fn interpolate_document(text: &str, path: &Path) -> CliResult<String> {
874 use crate::interpolate::interpolate_value;
875 let ext = path
876 .extension()
877 .and_then(|e| e.to_str())
878 .map(str::to_ascii_lowercase);
879 match ext.as_deref() {
880 Some("yaml" | "yml") => {
881 let mut value: serde_json::Value =
882 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
883 path: path.to_path_buf(),
884 message: friendly_parse_error(&e.to_string()),
885 })?;
886 interpolate_value(&mut value)?;
887 serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
888 path: path.to_path_buf(),
889 message: e.to_string(),
890 })
891 }
892 Some("json") => {
893 let mut value: serde_json::Value =
894 serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
895 path: path.to_path_buf(),
896 message: friendly_parse_error(&e.to_string()),
897 })?;
898 interpolate_value(&mut value)?;
899 serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
900 path: path.to_path_buf(),
901 message: e.to_string(),
902 })
903 }
904 _ => Err(CliError::UnknownExtension {
905 path: path.to_path_buf(),
906 }),
907 }
908}
909
910impl PipelineConfig {
911 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
924 let path = path.as_ref();
925 let composed = crate::compose::compose(path, profile)?;
926 let interpolated = interpolate_document(&composed, path)?;
927 let cfg = Self::from_text(&interpolated, path)?;
928 crate::secrets::ensure_no_secret_directives(&cfg)?;
931 Ok(cfg)
932 }
933
934 pub fn from_path_tolerating_secrets(
938 path: impl AsRef<Path>,
939 profile: Option<&str>,
940 ) -> CliResult<Self> {
941 let path = path.as_ref();
942 let composed = crate::compose::compose(path, profile)?;
943 let interpolated = interpolate_document(&composed, path)?;
944 Self::from_text(&interpolated, path)
945 }
946
947 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
951 let path = path.as_ref();
952 let composed = crate::compose::compose(path, profile)?;
953 let interpolated = interpolate_document(&composed, path)?;
954 let mut cfg = Self::from_text(&interpolated, path)?;
955 crate::secrets::resolve_secrets(&mut cfg).await?;
956 Ok(cfg)
957 }
958
959 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
962 let ext = path
963 .extension()
964 .and_then(|e| e.to_str())
965 .map(str::to_ascii_lowercase);
966 let cfg: PipelineConfig = match ext.as_deref() {
967 Some("yaml" | "yml") => {
968 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
969 path: path.to_path_buf(),
970 message: friendly_parse_error(&e.to_string()),
971 })?
972 }
973 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
974 path: path.to_path_buf(),
975 message: friendly_parse_error(&e.to_string()),
976 })?,
977 _ => {
978 return Err(CliError::UnknownExtension {
979 path: path.to_path_buf(),
980 });
981 }
982 };
983 Self::finish(cfg, path)
984 }
985
986 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
994 let synthetic = Path::new("<submitted>");
995 let cfg: PipelineConfig =
996 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
997 path: synthetic.to_path_buf(),
998 message: friendly_parse_error(&e.to_string()),
999 })?;
1000 Self::finish(cfg, synthetic)
1001 }
1002
1003 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
1005 if cfg.version != 1 {
1006 return Err(CliError::ParseConfig {
1007 path: path.to_path_buf(),
1008 message: format!(
1009 "unsupported pipeline version {}, only version 1 is recognised",
1010 cfg.version
1011 ),
1012 });
1013 }
1014 crate::interpolate::resolve_config_refs(&mut cfg)?;
1015 if let Some(obs) = cfg.observability.as_ref()
1016 && let Some(otel) = obs.otel.as_ref()
1017 {
1018 otel.to_core().map_err(CliError::Config)?;
1019 }
1020 Ok(cfg)
1021 }
1022}
1023
1024fn friendly_parse_error(raw: &str) -> String {
1027 let lower = raw.to_ascii_lowercase();
1028 if lower.contains("missing field `pipeline`") {
1029 return format!(
1030 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
1031 );
1032 }
1033 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
1034 return format!(
1035 "{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."
1036 );
1037 }
1038 raw.to_owned()
1039}
1040
1041pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
1044 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
1045 PipelineConfig::from_text(text, &synthetic)
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051 use serde_json::json;
1052
1053 #[test]
1054 fn parses_minimal_pipeline_yaml() {
1055 let yaml = r#"
1056version: 1
1057pipeline:
1058 source:
1059 type: rest
1060 config:
1061 base_url: https://api.example.com
1062 sink:
1063 type: jsonl
1064 config:
1065 path: ./out.jsonl
1066"#;
1067 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1068 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1069 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
1070 assert!(cfg.matrix.is_empty());
1071 assert!(cfg.execution.is_none());
1072 assert!(cfg.pipeline.transforms.is_empty());
1073 assert!(cfg.pipeline.state.is_none());
1074 }
1075
1076 #[test]
1077 fn parses_replication_block() {
1078 let yaml = r#"
1079version: 1
1080pipeline:
1081 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
1082 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1083 state: { type: file, config: { path: ./st } }
1084replication:
1085 mode: snapshot_then_cdc
1086 snapshot:
1087 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
1088"#;
1089 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1090 let r = cfg.replication.expect("replication parsed");
1091 assert_eq!(r.snapshot.source.kind, "postgres");
1092 }
1093
1094 #[test]
1095 fn pipeline_spec_parses_schema_block() {
1096 let yaml = r#"
1097version: 1
1098pipeline:
1099 source:
1100 type: rest
1101 config:
1102 base_url: https://api.example.com
1103 sink:
1104 type: jsonl
1105 config:
1106 path: ./out.jsonl
1107 schema:
1108 on_drift: evolve
1109 allow_type_widening: false
1110"#;
1111 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1112 let schema = cfg.pipeline.schema.expect("schema block parsed");
1113 assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
1114 assert!(!schema.allow_type_widening);
1115 }
1116
1117 #[test]
1118 fn parses_minimal_json() {
1119 let raw = r#"{
1120 "version": 1,
1121 "pipeline": {
1122 "source": {"type": "rest", "config": {}},
1123 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
1124 }
1125 }"#;
1126 let cfg = parse_with_extension(raw, "json").unwrap();
1127 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1128 }
1129
1130 #[test]
1131 fn parses_matrix_rows_with_partial_overrides() {
1132 let yaml = r#"
1133version: 1
1134pipeline:
1135 source: { type: rest, config: { base_url: https://api.example.com } }
1136 sink: { type: jsonl, config: { path: ./out.jsonl } }
1137matrix:
1138 - id: users
1139 source: { config: { path: /v1/users } }
1140 sink: { config: { path: ./users.jsonl } }
1141 - id: posts
1142 parent: users
1143 parent_key: user_id
1144 source: { config: { path: "/v1/users/${users.id}/posts" } }
1145"#;
1146 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1147 assert_eq!(cfg.matrix.len(), 2);
1148 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
1149 assert!(cfg.matrix[0].parent.is_none());
1150 let users_src = cfg.matrix[0].source.as_ref().unwrap();
1151 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
1152
1153 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
1154 assert_eq!(cfg.matrix[1].parent_key, "user_id");
1155 }
1156
1157 #[test]
1158 fn parent_key_defaults_to_id() {
1159 let yaml = r#"
1160version: 1
1161pipeline:
1162 source: { type: rest, config: {} }
1163 sink: { type: jsonl, config: { path: ./o.jsonl } }
1164matrix:
1165 - { id: users }
1166 - { id: posts, parent: users }
1167"#;
1168 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1169 assert_eq!(cfg.matrix[1].parent_key, "id");
1170 }
1171
1172 #[test]
1173 fn parses_execution_block() {
1174 let yaml = r#"
1175version: 1
1176pipeline:
1177 source: { type: rest, config: {} }
1178 sink: { type: jsonl, config: { path: ./o.jsonl } }
1179execution:
1180 max_concurrent: 8
1181 on_error: stop
1182"#;
1183 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1184 let exec = cfg.execution.unwrap();
1185 assert_eq!(exec.max_concurrent, Some(8));
1186 assert_eq!(exec.on_error, OnError::Stop);
1187 }
1188
1189 #[test]
1190 fn on_error_defaults_to_continue() {
1191 let yaml = r#"
1192version: 1
1193pipeline:
1194 source: { type: rest, config: {} }
1195 sink: { type: jsonl, config: { path: ./o.jsonl } }
1196execution: { max_concurrent: 2 }
1197"#;
1198 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1199 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1200 }
1201
1202 #[test]
1203 fn rejects_old_top_level_source_sink_with_hint() {
1204 let yaml = r#"
1206version: 1
1207source: { type: rest, config: {} }
1208sink: { type: jsonl, config: { path: ./o.jsonl } }
1209"#;
1210 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1211 let msg = err.to_string();
1212 assert!(
1213 msg.contains("pipeline"),
1214 "expected a hint about wrapping in `pipeline:`, got: {msg}"
1215 );
1216 }
1217
1218 #[test]
1219 fn rejects_unknown_extension() {
1220 let text = "version: 1\n";
1221 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1222 assert!(matches!(err, CliError::UnknownExtension { .. }));
1223 }
1224
1225 #[test]
1226 fn rejects_future_version() {
1227 let yaml = r#"
1228version: 99
1229pipeline:
1230 source: { type: rest, config: {} }
1231 sink: { type: jsonl, config: { path: ./x } }
1232"#;
1233 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1234 match err {
1235 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1236 other => panic!("expected ParseConfig, got {other:?}"),
1237 }
1238 }
1239
1240 #[test]
1241 fn transforms_and_state_round_trip() {
1242 let yaml = r#"
1243version: 1
1244pipeline:
1245 source:
1246 type: rest
1247 config: {}
1248 transforms:
1249 - type: snake_case
1250 - type: flatten
1251 config: { separator: "__" }
1252 sink:
1253 type: jsonl
1254 config: { path: "./out.jsonl" }
1255 state:
1256 type: file
1257 config: { path: "./.faucet-state" }
1258"#;
1259 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1260 assert_eq!(cfg.pipeline.transforms.len(), 2);
1261 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1262 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1263 assert_eq!(
1264 cfg.pipeline.transforms[1].config,
1265 json!({"separator": "__"})
1266 );
1267 let state = cfg.pipeline.state.unwrap();
1268 assert_eq!(state.kind, "file");
1269 }
1270
1271 #[test]
1272 fn from_path_interpolates_env_var() {
1273 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1274 let dir = tempfile::tempdir().unwrap();
1275 let path = dir.path().join("pipeline.yaml");
1276 std::fs::write(
1277 &path,
1278 r#"
1279version: 1
1280pipeline:
1281 source:
1282 type: rest
1283 config:
1284 base_url: ${env:FAUCET_CFG_URL}
1285 sink:
1286 type: jsonl
1287 config:
1288 path: ./out.jsonl
1289"#,
1290 )
1291 .unwrap();
1292 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1293 assert_eq!(
1294 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1295 "https://x.example"
1296 );
1297 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1298 }
1299
1300 #[test]
1301 fn observability_block_parses() {
1302 let y = r#"
1303version: 1
1304name: x
1305observability:
1306 prometheus:
1307 listen: "127.0.0.1:9464"
1308 buckets: [0.01, 0.1, 1.0]
1309 tracing:
1310 level: "info"
1311pipeline:
1312 source:
1313 type: rest
1314 config:
1315 base_url: "https://example.com"
1316 path: "/data"
1317 sink:
1318 type: jsonl
1319 config:
1320 path: "/tmp/faucet-test.jsonl"
1321"#;
1322 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1323 let obs = cfg.observability.expect("observability block parsed");
1324 let p = obs.prometheus.expect("prometheus parsed");
1325 assert_eq!(p.listen, "127.0.0.1:9464");
1326 assert_eq!(p.buckets.unwrap().len(), 3);
1327 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1328 }
1329
1330 #[test]
1331 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1332 let dir = tempfile::tempdir().unwrap();
1335 let path = dir.path().join("pipeline.yaml");
1336 std::fs::write(
1337 &path,
1338 r#"
1339version: 1
1340pipeline:
1341 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1342 sink: { type: jsonl, config: { path: ./o.jsonl } }
1343"#,
1344 )
1345 .unwrap();
1346 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1347 assert_eq!(
1348 cfg.pipeline.source.as_ref().unwrap().config["path"],
1349 "/v1/users/${users.id}/posts"
1350 );
1351 }
1352
1353 #[cfg(feature = "schedule")]
1354 #[test]
1355 fn parses_schedule_block() {
1356 let yaml = r#"
1357version: 1
1358schedule:
1359 cron: "0 2 * * *"
1360 timezone: "America/Los_Angeles"
1361 overlap_policy: skip
1362 max_consecutive_failures: 5
1363pipeline:
1364 source: { type: rest, config: {} }
1365 sink: { type: jsonl, config: { path: ./o.jsonl } }
1366"#;
1367 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1368 let s = cfg.schedule.expect("schedule parsed");
1369 assert_eq!(s.cron, "0 2 * * *");
1370 assert_eq!(s.timezone, "America/Los_Angeles");
1371 assert_eq!(s.max_consecutive_failures, Some(5));
1372 }
1373
1374 #[test]
1375 fn execution_spec_parses_adaptive_block() {
1376 let yaml = r#"
1377version: 1
1378pipeline:
1379 source: { type: rest, config: { base_url: https://api.example.com } }
1380 sink: { type: jsonl, config: { path: ./out.jsonl } }
1381execution:
1382 adaptive_batch_size:
1383 enabled: true
1384 min: 200
1385 max: 4000
1386 target_latency_ms: 800
1387"#;
1388 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1389 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1390 assert!(ab.enabled);
1391 assert_eq!(ab.min, 200);
1392 assert_eq!(ab.target_latency_ms, Some(800));
1393 ab.validate().unwrap();
1394 }
1395
1396 #[cfg(feature = "quality")]
1397 #[test]
1398 fn parses_quality_block() {
1399 let yaml = r#"
1400version: 1
1401pipeline:
1402 source: { type: rest, config: { url: "https://x" } }
1403 quality:
1404 record:
1405 - { type: not_null, field: id, on_failure: abort }
1406 sink: { type: stdout, config: {} }
1407"#;
1408 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1409 let q = cfg.pipeline.quality.expect("quality parsed");
1410 assert_eq!(q.record.len(), 1);
1411 }
1412
1413 #[cfg(feature = "contract")]
1414 #[test]
1415 fn parses_contract_block() {
1416 let yaml = r#"
1417version: 1
1418pipeline:
1419 source: { type: rest, config: { url: "https://x" } }
1420 contract:
1421 version: "1.0.0"
1422 on_breach: warn
1423 fields:
1424 - { name: id, type: integer }
1425 - { name: status, type: string, enum: [open, closed] }
1426 sink: { type: stdout, config: {} }
1427"#;
1428 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1429 let c = cfg.pipeline.contract.expect("contract parsed");
1430 assert_eq!(c.version, "1.0.0");
1431 assert_eq!(c.on_breach, faucet_core::OnBreach::Warn);
1432 assert_eq!(c.fields.len(), 2);
1433 }
1434
1435 #[test]
1436 fn parses_dlq_block_with_defaults() {
1437 let yaml = r#"
1438version: 1
1439pipeline:
1440 source: { type: rest, config: {} }
1441 sink: { type: jsonl, config: { path: ./o.jsonl } }
1442 dlq:
1443 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1444"#;
1445 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1446 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1447 assert_eq!(dlq.sink.kind, "jsonl");
1448 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1449 assert!(dlq.max_failures_per_page.is_none());
1450 assert!(dlq.max_failures_total.is_none());
1451 assert!(dlq.include_original_payload);
1452 }
1453
1454 #[test]
1455 fn parses_dlq_block_with_dlq_all_and_budgets() {
1456 let yaml = r#"
1457version: 1
1458pipeline:
1459 source: { type: rest, config: {} }
1460 sink: { type: jsonl, config: { path: ./o.jsonl } }
1461 dlq:
1462 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1463 on_batch_error: dlq_all
1464 max_failures_per_page: 100
1465 max_failures_total: 10000
1466"#;
1467 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1468 let dlq = cfg.pipeline.dlq.unwrap();
1469 assert_eq!(dlq.sink.kind, "kafka");
1470 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1471 assert_eq!(dlq.max_failures_per_page, Some(100));
1472 assert_eq!(dlq.max_failures_total, Some(10000));
1473 }
1474
1475 #[test]
1476 fn matrix_row_dlq_null_disables_inherited_dlq() {
1477 let yaml = r#"
1478version: 1
1479pipeline:
1480 source: { type: rest, config: {} }
1481 sink: { type: jsonl, config: { path: ./o.jsonl } }
1482 dlq:
1483 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1484matrix:
1485 - id: a
1486 - id: b
1487 dlq: null
1488"#;
1489 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1490 assert!(cfg.matrix[0].dlq.is_none());
1491 assert_eq!(cfg.matrix[1].dlq, Some(None));
1492 }
1493
1494 #[test]
1495 fn matrix_row_dlq_object_replaces_inherited_dlq() {
1496 let yaml = r#"
1497version: 1
1498pipeline:
1499 source: { type: rest, config: {} }
1500 sink: { type: jsonl, config: { path: ./o.jsonl } }
1501 dlq:
1502 sink: { type: jsonl, config: { path: ./base.jsonl } }
1503matrix:
1504 - id: a
1505 dlq:
1506 sink: { type: jsonl, config: { path: ./a.jsonl } }
1507 on_batch_error: dlq_all
1508"#;
1509 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1510 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1511 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1512 let sink_path = row_dlq.sink.config.get("path").unwrap();
1513 assert_eq!(sink_path, "./a.jsonl");
1514 }
1515
1516 #[test]
1517 fn parses_named_sources_and_sinks() {
1518 let yaml = r#"
1519version: 1
1520pipeline:
1521 sources:
1522 users_api:
1523 type: rest
1524 config: { base_url: https://api.example.com }
1525 posts_api:
1526 type: rest
1527 config: { base_url: https://api.example.com }
1528 sinks:
1529 warehouse:
1530 type: postgres
1531 config: { connection_url: "postgres://x" }
1532"#;
1533 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1534 assert!(cfg.pipeline.source.is_none());
1535 assert!(cfg.pipeline.sink.is_none());
1536 assert_eq!(cfg.pipeline.sources.len(), 2);
1537 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1538 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1539 }
1540
1541 #[test]
1542 fn legacy_singular_source_still_parses() {
1543 let yaml = r#"
1544version: 1
1545pipeline:
1546 source: { type: rest, config: {} }
1547 sink: { type: jsonl, config: { path: ./o.jsonl } }
1548"#;
1549 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1550 assert!(cfg.pipeline.source.is_some());
1551 assert!(cfg.pipeline.sink.is_some());
1552 assert!(cfg.pipeline.sources.is_empty());
1553 assert!(cfg.pipeline.sinks.is_empty());
1554 }
1555
1556 #[test]
1557 fn parses_matrix_row_with_ref_field() {
1558 let yaml = r#"
1559version: 1
1560pipeline:
1561 source: { type: rest, config: {} }
1562 sink: { type: jsonl, config: { path: ./o.jsonl } }
1563matrix:
1564 - id: load_users
1565 source:
1566 ref: users_api
1567 config: { path: /v1/users }
1568"#;
1569 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1570 let src = cfg.matrix[0].source.as_ref().unwrap();
1571 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1572 assert_eq!(src.kind, None);
1573 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1574 }
1575
1576 #[test]
1577 fn parses_top_level_vars_block() {
1578 let yaml = r#"
1579version: 1
1580vars:
1581 api_base: https://api.example.com
1582 api_token_env: API_TOKEN
1583pipeline:
1584 source: { type: rest, config: {} }
1585 sink: { type: jsonl, config: { path: ./o.jsonl } }
1586"#;
1587 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1588 let vars = cfg.vars.as_ref().unwrap();
1589 assert_eq!(vars["api_base"], "https://api.example.com");
1590 assert_eq!(vars["api_token_env"], "API_TOKEN");
1591 }
1592
1593 #[test]
1594 fn vars_block_is_optional() {
1595 let yaml = r#"
1596version: 1
1597pipeline:
1598 source: { type: rest, config: {} }
1599 sink: { type: jsonl, config: { path: ./o.jsonl } }
1600"#;
1601 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1602 assert!(cfg.vars.is_none());
1603 }
1604
1605 #[test]
1606 fn from_path_resolves_vars_at_load() {
1607 let dir = tempfile::tempdir().unwrap();
1608 let path = dir.path().join("pipeline.yaml");
1609 std::fs::write(
1610 &path,
1611 r#"
1612version: 1
1613vars:
1614 base: https://api.example.com
1615pipeline:
1616 source: { type: rest, config: { url: "${vars.base}/v1" } }
1617 sink: { type: jsonl, config: { path: ./o.jsonl } }
1618"#,
1619 )
1620 .unwrap();
1621 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1622 assert_eq!(
1623 cfg.pipeline.source.as_ref().unwrap().config["url"],
1624 "https://api.example.com/v1"
1625 );
1626 }
1627
1628 #[test]
1629 fn sync_from_path_errors_on_secret_directive() {
1630 let dir = tempfile::tempdir().unwrap();
1631 let path = dir.path().join("p.yaml");
1632 std::fs::write(
1633 &path,
1634 r#"
1635version: 1
1636pipeline:
1637 source: { type: rest, config: { url: "${vault:secret/x}" } }
1638 sink: { type: jsonl, config: { path: ./o.jsonl } }
1639"#,
1640 )
1641 .unwrap();
1642 match PipelineConfig::from_path(&path, None).unwrap_err() {
1643 CliError::SecretsRequireAsyncLoad => {}
1644 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1645 }
1646 }
1647
1648 #[test]
1649 fn from_value_accepts_v1_and_resolves_refs() {
1650 let v = serde_json::json!({
1651 "version": 1,
1652 "vars": { "out": "resolved.jsonl" },
1653 "pipeline": {
1654 "source": { "type": "csv", "config": { "path": "x.csv" } },
1655 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1656 }
1657 });
1658 let cfg = PipelineConfig::from_value(v).unwrap();
1659 assert_eq!(cfg.version, 1);
1660 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1662 }
1663
1664 #[test]
1665 fn from_value_rejects_non_v1() {
1666 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1668 let err = PipelineConfig::from_value(v).unwrap_err();
1669 match err {
1670 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1671 other => panic!("expected ParseConfig, got {other:?}"),
1672 }
1673 }
1674
1675 #[tokio::test]
1676 async fn async_from_path_loads_without_secrets() {
1677 let dir = tempfile::tempdir().unwrap();
1678 let path = dir.path().join("p.yaml");
1679 std::fs::write(
1680 &path,
1681 r#"
1682version: 1
1683pipeline:
1684 source: { type: rest, config: { base_url: https://x } }
1685 sink: { type: jsonl, config: { path: ./o.jsonl } }
1686"#,
1687 )
1688 .unwrap();
1689 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1690 assert_eq!(cfg.version, 1);
1691 }
1692
1693 #[cfg(feature = "lineage")]
1694 #[test]
1695 fn parses_lineage_block() {
1696 let yaml = r#"
1697version: 1
1698lineage:
1699 namespace: prod
1700 transport: { type: file, config: { path: /tmp/ol.jsonl } }
1701pipeline:
1702 source: { type: rest, config: {} }
1703 sink: { type: jsonl, config: { path: ./o.jsonl } }
1704"#;
1705 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1706 let l = cfg.lineage.expect("lineage parsed");
1707 assert_eq!(l.namespace, "prod");
1708 }
1709
1710 #[test]
1711 fn from_path_resolves_extends_and_profile() {
1712 let dir = tempfile::tempdir().unwrap();
1713 std::fs::write(
1714 dir.path().join("base.yaml"),
1715 "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",
1716 )
1717 .unwrap();
1718 let app = dir.path().join("app.yaml");
1719 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1720
1721 let cfg = PipelineConfig::from_path(&app, None).unwrap();
1723 assert_eq!(
1724 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1725 "base.jsonl"
1726 );
1727
1728 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1730 assert_eq!(
1731 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1732 "prod.jsonl"
1733 );
1734 }
1735
1736 #[test]
1737 fn from_value_rejects_extends_with_composition_hint() {
1738 let v = serde_json::json!({
1740 "version": 1,
1741 "extends": "base.yaml",
1742 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1743 });
1744 let err = PipelineConfig::from_value(v).unwrap_err();
1745 let msg = err.to_string();
1746 assert!(
1747 msg.contains("composition"),
1748 "expected composition hint, got: {msg}"
1749 );
1750 }
1751
1752 #[test]
1753 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1754 let yaml = r#"
1756version: 1
1757pipeline:
1758 source: { type: rest, config: {} }
1759 sink: { type: jsonl, config: { path: ./o.jsonl } }
1760"#;
1761 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1762 assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1763
1764 let yaml2 = r#"
1766version: 1
1767delivery: exactly_once
1768pipeline:
1769 source: { type: rest, config: {} }
1770 sink: { type: jsonl, config: { path: ./o.jsonl } }
1771"#;
1772 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
1773 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
1774
1775 let yaml3 = r#"
1777version: 1
1778delivery: at_least_once
1779pipeline:
1780 source: { type: rest, config: {} }
1781 sink: { type: jsonl, config: { path: ./o.jsonl } }
1782matrix:
1783 - id: a
1784 - id: b
1785 delivery: exactly_once
1786"#;
1787 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
1788 assert_eq!(cfg3.matrix[0].delivery, None);
1789 assert_eq!(
1790 cfg3.matrix[1].delivery,
1791 Some(faucet_core::DeliveryMode::ExactlyOnce)
1792 );
1793 }
1794
1795 #[test]
1796 fn resilience_spec_parses_and_builds_policy() {
1797 let yaml = r#"
1798version: 1
1799pipeline:
1800 source: { type: rest, config: { base_url: "https://x" } }
1801 sink: { type: stdout, config: {} }
1802resilience:
1803 retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
1804 retry_on: [http_5xx, timeout]
1805 circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
1806 poison: { max_row_attempts: 2, action: dlq }
1807"#;
1808 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1809 let spec = cfg.resilience.unwrap();
1810 let policy = spec.to_policy().unwrap();
1811 assert_eq!(policy.retry.max_attempts, 4);
1812 assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
1813 assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
1814 }
1815
1816 #[test]
1817 fn resilience_rejects_zero_max_attempts() {
1818 let yaml = r#"
1819version: 1
1820pipeline:
1821 source: { type: rest, config: { base_url: "https://x" } }
1822 sink: { type: stdout, config: {} }
1823resilience: { retry: { max_attempts: 0 } }
1824"#;
1825 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1826 let err = cfg.resilience.unwrap().to_policy().unwrap_err();
1827 assert!(err.to_string().contains("max_attempts"));
1828 }
1829
1830 #[test]
1831 fn observability_parses_otel_block() {
1832 let yaml = r#"
1833version: 1
1834pipeline:
1835 source: { type: rest, config: { base_url: "http://x" } }
1836 sink: { type: stdout, config: {} }
1837observability:
1838 otel:
1839 endpoint: http://collector:4317
1840 protocol: grpc
1841 export: [traces, metrics]
1842"#;
1843 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1844 let otel = cfg.observability.unwrap().otel.unwrap();
1845 assert_eq!(otel.endpoint, "http://collector:4317");
1846 }
1847
1848 #[test]
1849 fn otel_validation_rejects_bad_ratio() {
1850 let yaml = r#"
1851version: 1
1852pipeline:
1853 source: { type: rest, config: { base_url: "http://x" } }
1854 sink: { type: stdout, config: {} }
1855observability:
1856 otel:
1857 sample_ratio: 9.0
1858"#;
1859 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1860 assert!(format!("{err}").contains("sample_ratio"));
1861 }
1862}