1use crate::error::{CliError, CliResult};
32use crate::params::ParamsSpec;
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36use std::collections::HashMap;
37use std::path::{Path, PathBuf};
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct PipelineConfig {
43 #[serde(default = "default_version")]
45 pub version: u32,
46
47 #[serde(default)]
49 pub name: Option<String>,
50
51 #[serde(default)]
55 pub vars: Option<HashMap<String, Value>>,
56
57 #[serde(default, skip_serializing_if = "ParamsSpec::is_empty")]
65 pub params: ParamsSpec,
66
67 #[serde(default)]
72 pub auth: Option<HashMap<String, Value>>,
73
74 pub pipeline: PipelineSpec,
76
77 #[serde(default)]
80 pub matrix: Vec<MatrixRow>,
81
82 #[serde(default)]
84 pub execution: Option<ExecutionSpec>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub selection: Option<SelectionSpec>,
92
93 #[serde(default)]
95 pub observability: Option<ObservabilitySpec>,
96
97 #[serde(default)]
102 pub delivery: faucet_core::DeliveryMode,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub resilience: Option<ResilienceSpec>,
109
110 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub sla: Option<crate::sla::SlaSpec>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub shard: Option<ShardingSpec>,
126
127 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub replication: Option<crate::replication::spec::ReplicationSpec>,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub backfill: Option<crate::backfill::BackfillSpec>,
137
138 #[cfg(feature = "schedule")]
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
143
144 #[cfg(feature = "lineage")]
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub lineage: Option<faucet_lineage::LineageConfig>,
148
149 #[cfg(feature = "catalog")]
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub catalog: Option<crate::catalog::CatalogSpec>,
157
158 #[cfg(feature = "notify")]
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub notifications: Vec<crate::notify::NotificationSpec>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
172#[serde(deny_unknown_fields)]
173pub struct PipelineSpec {
174 #[serde(default)]
177 pub source: Option<ConnectorSpec>,
178
179 #[serde(default)]
181 pub sink: Option<ConnectorSpec>,
182
183 #[serde(default)]
185 pub sources: HashMap<String, ConnectorSpec>,
186
187 #[serde(default)]
189 pub sinks: HashMap<String, ConnectorSpec>,
190
191 #[serde(default)]
192 pub transforms: Vec<TransformSpec>,
193 #[serde(default)]
194 pub state: Option<StateStoreSpec>,
195 #[serde(default)]
196 pub dlq: Option<DlqSpec>,
197
198 #[cfg(feature = "quality")]
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub quality: Option<faucet_core::QualitySpec>,
202
203 #[cfg(feature = "contract")]
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub contract: Option<faucet_core::ContractSpec>,
209
210 #[cfg(feature = "masking")]
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub masking: Option<faucet_core::MaskingSpec>,
219
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub schema: Option<faucet_core::SchemaDriftSpec>,
223
224 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
229 pub nodes: HashMap<String, NodeSpec>,
230
231 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub edges: Vec<EdgeSpec>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
247#[serde(tag = "kind", rename_all = "lowercase")]
248pub enum NodeSpec {
249 Source {
251 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
253 template: Option<String>,
254 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
256 kind: Option<String>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 config: Option<Value>,
260 },
261 Sink {
263 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
265 template: Option<String>,
266 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
268 kind: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 config: Option<Value>,
272 },
273 Transform {
275 #[serde(default)]
277 transforms: Vec<TransformSpec>,
278 },
279 Tee {
281 #[serde(default = "default_channel_capacity")]
283 channel_capacity: usize,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 fanout: Option<usize>,
287 },
288 Merge,
290 Join(JoinSpec),
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
296#[serde(deny_unknown_fields)]
297pub struct JoinSpec {
298 #[serde(default)]
300 pub mode: faucet_core::JoinMode,
301 pub build: JoinSide,
303 pub probe: JoinSide,
305 #[serde(default)]
307 pub project: Vec<faucet_core::Projection>,
308 #[serde(default)]
310 pub on_missing: Value,
311 #[serde(default)]
313 pub on_duplicate: faucet_core::OnDuplicate,
314 #[serde(default)]
316 pub on_collision: faucet_core::OnCollision,
317 #[serde(default)]
319 pub key_normalize: faucet_core::KeyNormalize,
320 #[serde(default = "default_max_build_records")]
322 pub max_build_records: usize,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
327#[serde(deny_unknown_fields)]
328pub struct JoinSide {
329 pub edge: String,
331 pub key: String,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
337#[serde(deny_unknown_fields)]
338pub struct EdgeSpec {
339 pub from: String,
341 pub to: String,
343 #[serde(rename = "as", default, skip_serializing_if = "Option::is_none")]
345 pub label: Option<String>,
346}
347
348fn default_channel_capacity() -> usize {
349 faucet_core::topology::DEFAULT_CHANNEL_CAPACITY
350}
351
352fn default_max_build_records() -> usize {
353 faucet_core::join::DEFAULT_MAX_BUILD_RECORDS
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
361#[serde(deny_unknown_fields)]
362pub struct ConnectorSpec {
363 #[serde(rename = "type")]
366 pub kind: String,
367
368 #[serde(default = "empty_object")]
371 pub config: Value,
372
373 #[serde(default)]
377 pub transforms: Option<Vec<TransformSpec>>,
378
379 #[serde(default = "default_true")]
383 pub inherit_transforms: bool,
384
385 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub status: Option<SourceStatus>,
391
392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
397 pub tags: Vec<String>,
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
407#[serde(deny_unknown_fields)]
408pub struct PartialConnector {
409 #[serde(default)]
412 pub r#ref: Option<String>,
413 #[serde(rename = "type", default)]
415 pub kind: Option<String>,
416 #[serde(default)]
418 pub config: Option<Value>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
423 pub status: Option<SourceStatus>,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
428#[serde(deny_unknown_fields)]
429pub struct TransformSpec {
430 #[serde(rename = "type")]
435 pub kind: String,
436
437 #[serde(default = "empty_object")]
439 pub config: Value,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
444#[serde(deny_unknown_fields)]
445pub struct StateStoreSpec {
446 #[serde(rename = "type")]
448 pub kind: String,
449
450 #[serde(default = "empty_object")]
452 pub config: Value,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
457#[serde(deny_unknown_fields)]
458pub struct MatrixRow {
459 #[serde(default)]
462 pub id: Option<String>,
463
464 #[serde(default)]
466 pub parent: Option<String>,
467
468 #[serde(default)]
473 pub depends_on: Vec<String>,
474
475 #[serde(default = "default_parent_key")]
478 pub parent_key: String,
479
480 #[serde(default)]
482 pub source: Option<PartialConnector>,
483
484 #[serde(default)]
486 pub sink: Option<PartialConnector>,
487
488 #[serde(default)]
493 pub transforms: Option<Vec<TransformSpec>>,
494
495 #[serde(default = "default_true")]
498 pub inherit_transforms: bool,
499
500 #[serde(default)]
502 pub state: Option<StateStoreSpec>,
503
504 #[serde(default, deserialize_with = "deserialize_dlq_override")]
509 pub dlq: Option<Option<DlqSpec>>,
510
511 #[serde(default)]
513 pub delivery: Option<faucet_core::DeliveryMode>,
514
515 #[serde(default, skip_serializing_if = "Vec::is_empty")]
522 pub tags: Vec<String>,
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
530#[serde(rename_all = "snake_case")]
531pub enum SourceStatus {
532 Mandatory,
534 #[default]
536 Active,
537 Available,
539 Draft,
541 Archived,
543}
544
545impl SourceStatus {
546 pub const ALL: [SourceStatus; 5] = [
548 SourceStatus::Mandatory,
549 SourceStatus::Active,
550 SourceStatus::Available,
551 SourceStatus::Draft,
552 SourceStatus::Archived,
553 ];
554
555 pub fn as_str(self) -> &'static str {
557 match self {
558 SourceStatus::Mandatory => "mandatory",
559 SourceStatus::Active => "active",
560 SourceStatus::Available => "available",
561 SourceStatus::Draft => "draft",
562 SourceStatus::Archived => "archived",
563 }
564 }
565
566 pub fn parse(s: &str) -> Option<Self> {
569 SourceStatus::ALL.into_iter().find(|v| v.as_str() == s)
570 }
571
572 pub fn default_eligible(self) -> bool {
574 matches!(self, SourceStatus::Mandatory | SourceStatus::Active)
575 }
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
584#[serde(rename_all = "snake_case")]
585pub enum IncludeParents {
586 #[default]
589 Off,
590 Eligible,
593 All,
596}
597
598impl IncludeParents {
599 pub fn as_str(self) -> &'static str {
601 match self {
602 IncludeParents::Off => "off",
603 IncludeParents::Eligible => "eligible",
604 IncludeParents::All => "all",
605 }
606 }
607
608 pub fn parse(s: &str) -> Option<Self> {
610 match s {
611 "off" => Some(IncludeParents::Off),
612 "eligible" => Some(IncludeParents::Eligible),
613 "all" => Some(IncludeParents::All),
614 _ => None,
615 }
616 }
617}
618
619#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
622#[serde(deny_unknown_fields)]
623pub struct SelectionSpec {
624 #[serde(default)]
626 pub include_parents: IncludeParents,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
631#[serde(deny_unknown_fields)]
632pub struct ExecutionSpec {
633 #[serde(default)]
637 pub max_concurrent: Option<usize>,
638
639 #[serde(default)]
641 pub on_error: OnError,
642
643 #[serde(default)]
645 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
646}
647
648#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
650#[serde(deny_unknown_fields)]
651pub struct ShardingSpec {
652 pub count: usize,
657}
658
659#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
661#[serde(rename_all = "lowercase")]
662pub enum OnError {
663 #[default]
665 Continue,
666 Stop,
668}
669
670#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
672pub struct ObservabilitySpec {
673 #[serde(default)]
675 pub prometheus: Option<PrometheusSpec>,
676
677 #[serde(default)]
679 pub tracing: Option<TracingSpec>,
680
681 #[serde(default)]
683 pub otel: Option<OtelSpec>,
684}
685
686#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
688pub struct PrometheusSpec {
689 pub listen: String,
691
692 #[serde(default)]
695 pub buckets: Option<Vec<f64>>,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
700pub struct TracingSpec {
701 #[serde(default)]
704 pub level: Option<String>,
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
709pub struct OtelSpec {
710 #[serde(default)]
713 pub endpoint: String,
714 #[serde(default)]
716 pub protocol: faucet_core::OtelProtocol,
717 #[serde(default)]
719 pub headers: std::collections::HashMap<String, String>,
720 #[serde(default = "default_otel_ratio")]
722 pub sample_ratio: f64,
723 #[serde(default = "default_otel_export")]
725 pub export: Vec<faucet_core::OtelSignal>,
726 #[serde(default = "default_otel_service")]
728 pub service_name: String,
729 #[serde(default = "default_otel_timeout")]
731 pub timeout_secs: u64,
732 #[serde(default = "default_otel_interval")]
734 pub metric_interval_secs: u64,
735}
736
737fn default_otel_ratio() -> f64 {
738 1.0
739}
740fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
741 vec![
742 faucet_core::OtelSignal::Traces,
743 faucet_core::OtelSignal::Metrics,
744 ]
745}
746fn default_otel_service() -> String {
747 "faucet".to_string()
748}
749fn default_otel_timeout() -> u64 {
750 10
751}
752fn default_otel_interval() -> u64 {
753 60
754}
755
756impl OtelSpec {
757 pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
759 let cfg = faucet_core::OtelConfig {
760 endpoint: self.endpoint.clone(),
761 protocol: self.protocol,
762 headers: self.headers.clone(),
763 sample_ratio: self.sample_ratio,
764 export: self.export.clone(),
765 service_name: self.service_name.clone(),
766 timeout_secs: self.timeout_secs,
767 metric_interval_secs: self.metric_interval_secs,
768 };
769 cfg.validate()?;
770 Ok(cfg)
771 }
772}
773
774#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
778#[serde(rename_all = "snake_case")]
779pub enum OnBatchErrorSpec {
780 #[default]
781 Propagate,
782 DlqAll,
783}
784
785#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
787#[serde(deny_unknown_fields)]
788pub struct DlqSpec {
789 pub sink: ConnectorSpec,
790 #[serde(default)]
791 pub on_batch_error: OnBatchErrorSpec,
792 #[serde(default)]
793 pub max_failures_per_page: Option<usize>,
794 #[serde(default)]
795 pub max_failures_total: Option<usize>,
796 #[serde(default = "default_true")]
797 pub include_original_payload: bool,
798}
799
800#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
802#[serde(deny_unknown_fields)]
803pub struct ResilienceSpec {
804 #[serde(default)]
807 pub retry: RetrySpec,
808 #[serde(default)]
810 pub retry_on: Option<Vec<faucet_core::RetryClass>>,
811 #[serde(default)]
813 pub circuit_breaker: Option<CircuitBreakerSpec>,
814 #[serde(default)]
816 pub poison: Option<PoisonSpec>,
817}
818
819#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
821#[serde(deny_unknown_fields)]
822pub struct RetrySpec {
823 #[serde(default = "default_max_attempts")]
825 pub max_attempts: u32,
826 #[serde(default)]
828 pub backoff: BackoffSpec,
829 #[serde(default = "default_base_ms")]
831 pub base_ms: u64,
832 #[serde(default = "default_max_ms")]
834 pub max_ms: u64,
835 #[serde(default = "default_true")]
837 pub jitter: bool,
838}
839
840impl Default for RetrySpec {
841 fn default() -> Self {
842 Self {
843 max_attempts: default_max_attempts(),
844 backoff: BackoffSpec::default(),
845 base_ms: default_base_ms(),
846 max_ms: default_max_ms(),
847 jitter: true,
848 }
849 }
850}
851
852#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
854#[serde(rename_all = "snake_case")]
855pub enum BackoffSpec {
856 None,
858 Fixed,
860 #[default]
862 Exponential,
863}
864
865#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
867#[serde(deny_unknown_fields)]
868pub struct CircuitBreakerSpec {
869 pub consecutive_failures: u32,
871 pub cooldown_secs: u64,
873}
874
875#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
877#[serde(deny_unknown_fields)]
878pub struct PoisonSpec {
879 pub max_row_attempts: u32,
881 #[serde(default)]
883 pub action: PoisonActionSpec,
884}
885
886#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
889#[serde(rename_all = "snake_case")]
890pub enum PoisonActionSpec {
891 #[default]
893 Dlq,
894 Drop,
896 Fail,
898}
899
900fn default_max_attempts() -> u32 {
901 5
902}
903fn default_base_ms() -> u64 {
904 200
905}
906fn default_max_ms() -> u64 {
907 30_000
908}
909
910impl ResilienceSpec {
911 pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
913 use crate::error::CliError;
914 if self.retry.max_attempts < 1 {
915 return Err(CliError::Config(
916 "resilience.retry.max_attempts must be >= 1".into(),
917 ));
918 }
919 if self.retry.base_ms > self.retry.max_ms {
920 return Err(CliError::Config(
921 "resilience.retry.base_ms must be <= max_ms".into(),
922 ));
923 }
924 let retry_on = match &self.retry_on {
925 Some(v) if v.is_empty() => {
926 return Err(CliError::Config(
927 "resilience.retry_on must not be empty".into(),
928 ));
929 }
930 Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
931 None => faucet_core::RetryClassSet::default(),
932 };
933 let backoff = match self.retry.backoff {
934 BackoffSpec::None => faucet_core::BackoffKind::None,
935 BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
936 BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
937 };
938 let circuit_breaker = match self.circuit_breaker {
939 Some(cb) if cb.consecutive_failures < 1 => {
940 return Err(CliError::Config(
941 "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
942 ));
943 }
944 Some(cb) => Some(faucet_core::CircuitBreakerConfig {
945 consecutive_failures: cb.consecutive_failures,
946 cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
947 }),
948 None => None,
949 };
950 let poison = match self.poison {
951 Some(p) if p.max_row_attempts < 1 => {
952 return Err(CliError::Config(
953 "resilience.poison.max_row_attempts must be >= 1".into(),
954 ));
955 }
956 Some(p) => Some(faucet_core::PoisonPolicy {
957 max_row_attempts: p.max_row_attempts,
958 action: match p.action {
959 PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
960 PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
961 PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
962 },
963 }),
964 None => None,
965 };
966 Ok(faucet_core::ResiliencePolicy {
967 retry: faucet_core::RetryPolicy {
968 max_attempts: self.retry.max_attempts,
969 backoff,
970 base: std::time::Duration::from_millis(self.retry.base_ms),
971 max: std::time::Duration::from_millis(self.retry.max_ms),
972 jitter: self.retry.jitter,
973 retry_on,
974 },
975 circuit_breaker,
976 poison,
977 })
978 }
979}
980
981fn default_true() -> bool {
982 true
983}
984
985fn default_version() -> u32 {
986 1
987}
988fn default_parent_key() -> String {
989 "id".to_owned()
990}
991fn empty_object() -> Value {
992 Value::Object(Default::default())
993}
994
995fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
996where
997 D: serde::Deserializer<'de>,
998{
999 Option::<DlqSpec>::deserialize(deserializer).map(Some)
1000}
1001
1002#[derive(Debug, Clone)]
1022pub struct RunInputs {
1023 pub params: crate::params::SuppliedParams,
1026 pub env: crate::interpolate::EnvOverlay,
1029 pub mode: crate::params::BindMode,
1031}
1032
1033impl Default for RunInputs {
1034 fn default() -> Self {
1035 Self {
1036 params: Default::default(),
1037 env: Default::default(),
1038 mode: crate::params::BindMode::Strict,
1039 }
1040 }
1041}
1042
1043impl RunInputs {
1044 pub fn placeholders() -> Self {
1047 Self {
1048 mode: crate::params::BindMode::Placeholder,
1049 ..Self::default()
1050 }
1051 }
1052
1053 pub fn with_params(params: crate::params::SuppliedParams) -> Self {
1055 Self {
1056 params,
1057 ..Self::default()
1058 }
1059 }
1060}
1061
1062fn resolve_document(text: &str, path: &Path, inputs: &RunInputs) -> CliResult<String> {
1063 use crate::interpolate::interpolate_value_with_env;
1064 let ext = path
1065 .extension()
1066 .and_then(|e| e.to_str())
1067 .map(str::to_ascii_lowercase);
1068 let resolve = |value: &mut serde_json::Value| -> CliResult<()> {
1073 interpolate_value_with_env(value, &inputs.env)?;
1074 crate::params::bind_document(value, &inputs.params, inputs.mode)?;
1075 Ok(())
1076 };
1077 match ext.as_deref() {
1078 Some("yaml" | "yml") => {
1079 let mut value: serde_json::Value =
1080 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1081 path: path.to_path_buf(),
1082 message: friendly_parse_error(&e.to_string()),
1083 })?;
1084 resolve(&mut value)?;
1085 serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
1086 path: path.to_path_buf(),
1087 message: e.to_string(),
1088 })
1089 }
1090 Some("json") => {
1091 let mut value: serde_json::Value =
1092 serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1093 path: path.to_path_buf(),
1094 message: friendly_parse_error(&e.to_string()),
1095 })?;
1096 resolve(&mut value)?;
1097 serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
1098 path: path.to_path_buf(),
1099 message: e.to_string(),
1100 })
1101 }
1102 _ => Err(CliError::UnknownExtension {
1103 path: path.to_path_buf(),
1104 }),
1105 }
1106}
1107
1108impl PipelineConfig {
1109 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1122 Self::from_path_with(path, profile, &RunInputs::default())
1123 }
1124
1125 pub fn from_path_with(
1128 path: impl AsRef<Path>,
1129 profile: Option<&str>,
1130 inputs: &RunInputs,
1131 ) -> CliResult<Self> {
1132 let path = path.as_ref();
1133 let composed = crate::compose::compose(path, profile)?;
1134 let interpolated = resolve_document(&composed, path, inputs)?;
1135 let cfg = Self::from_text(&interpolated, path)?;
1136 crate::secrets::ensure_no_secret_directives(&cfg)?;
1139 Ok(cfg)
1140 }
1141
1142 pub fn from_path_tolerating_secrets(
1146 path: impl AsRef<Path>,
1147 profile: Option<&str>,
1148 ) -> CliResult<Self> {
1149 Self::from_path_tolerating_secrets_with(path, profile, &RunInputs::default())
1150 }
1151
1152 pub fn from_path_tolerating_secrets_with(
1154 path: impl AsRef<Path>,
1155 profile: Option<&str>,
1156 inputs: &RunInputs,
1157 ) -> CliResult<Self> {
1158 let path = path.as_ref();
1159 let composed = crate::compose::compose(path, profile)?;
1160 let interpolated = resolve_document(&composed, path, inputs)?;
1161 Self::from_text(&interpolated, path)
1162 }
1163
1164 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1168 Self::from_path_async_with(path, profile, &RunInputs::default()).await
1169 }
1170
1171 pub async fn from_path_async_with(
1174 path: impl AsRef<Path>,
1175 profile: Option<&str>,
1176 inputs: &RunInputs,
1177 ) -> CliResult<Self> {
1178 let path = path.as_ref();
1179 let composed = crate::compose::compose(path, profile)?;
1180 let interpolated = resolve_document(&composed, path, inputs)?;
1181 let mut cfg = Self::from_text(&interpolated, path)?;
1182 crate::secrets::resolve_secrets(&mut cfg).await?;
1183 Ok(cfg)
1184 }
1185
1186 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
1189 let ext = path
1190 .extension()
1191 .and_then(|e| e.to_str())
1192 .map(str::to_ascii_lowercase);
1193 let cfg: PipelineConfig = match ext.as_deref() {
1194 Some("yaml" | "yml") => {
1195 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1196 path: path.to_path_buf(),
1197 message: friendly_parse_error(&e.to_string()),
1198 })?
1199 }
1200 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1201 path: path.to_path_buf(),
1202 message: friendly_parse_error(&e.to_string()),
1203 })?,
1204 _ => {
1205 return Err(CliError::UnknownExtension {
1206 path: path.to_path_buf(),
1207 });
1208 }
1209 };
1210 Self::finish(cfg, path)
1211 }
1212
1213 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
1221 let synthetic = Path::new("<submitted>");
1222 let cfg: PipelineConfig =
1223 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
1224 path: synthetic.to_path_buf(),
1225 message: friendly_parse_error(&e.to_string()),
1226 })?;
1227 Self::finish(cfg, synthetic)
1228 }
1229
1230 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
1232 if cfg.version != 1 {
1233 return Err(CliError::ParseConfig {
1234 path: path.to_path_buf(),
1235 message: format!(
1236 "unsupported pipeline version {}, only version 1 is recognised",
1237 cfg.version
1238 ),
1239 });
1240 }
1241 crate::interpolate::resolve_config_refs(&mut cfg)?;
1242 if let Some(obs) = cfg.observability.as_ref()
1243 && let Some(otel) = obs.otel.as_ref()
1244 {
1245 otel.to_core().map_err(CliError::Config)?;
1246 }
1247 Ok(cfg)
1248 }
1249}
1250
1251fn friendly_parse_error(raw: &str) -> String {
1254 let lower = raw.to_ascii_lowercase();
1255 if lower.contains("missing field `pipeline`") {
1256 return format!(
1257 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
1258 );
1259 }
1260 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
1261 return format!(
1262 "{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."
1263 );
1264 }
1265 raw.to_owned()
1266}
1267
1268pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
1271 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
1272 PipelineConfig::from_text(text, &synthetic)
1273}
1274
1275#[cfg(test)]
1276mod tests {
1277 use super::*;
1278 use serde_json::json;
1279
1280 #[test]
1281 fn parses_minimal_pipeline_yaml() {
1282 let yaml = r#"
1283version: 1
1284pipeline:
1285 source:
1286 type: rest
1287 config:
1288 base_url: https://api.example.com
1289 sink:
1290 type: jsonl
1291 config:
1292 path: ./out.jsonl
1293"#;
1294 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1295 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1296 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
1297 assert!(cfg.matrix.is_empty());
1298 assert!(cfg.execution.is_none());
1299 assert!(cfg.pipeline.transforms.is_empty());
1300 assert!(cfg.pipeline.state.is_none());
1301 }
1302
1303 #[test]
1304 fn parses_replication_block() {
1305 let yaml = r#"
1306version: 1
1307pipeline:
1308 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
1309 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1310 state: { type: file, config: { path: ./st } }
1311replication:
1312 mode: snapshot_then_cdc
1313 snapshot:
1314 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
1315"#;
1316 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1317 let r = cfg.replication.expect("replication parsed");
1318 assert_eq!(r.snapshot.source.kind, "postgres");
1319 }
1320
1321 #[test]
1322 fn pipeline_spec_parses_schema_block() {
1323 let yaml = r#"
1324version: 1
1325pipeline:
1326 source:
1327 type: rest
1328 config:
1329 base_url: https://api.example.com
1330 sink:
1331 type: jsonl
1332 config:
1333 path: ./out.jsonl
1334 schema:
1335 on_drift: evolve
1336 allow_type_widening: false
1337"#;
1338 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1339 let schema = cfg.pipeline.schema.expect("schema block parsed");
1340 assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
1341 assert!(!schema.allow_type_widening);
1342 }
1343
1344 #[test]
1345 fn parses_minimal_json() {
1346 let raw = r#"{
1347 "version": 1,
1348 "pipeline": {
1349 "source": {"type": "rest", "config": {}},
1350 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
1351 }
1352 }"#;
1353 let cfg = parse_with_extension(raw, "json").unwrap();
1354 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1355 }
1356
1357 #[test]
1358 fn parses_matrix_rows_with_partial_overrides() {
1359 let yaml = r#"
1360version: 1
1361pipeline:
1362 source: { type: rest, config: { base_url: https://api.example.com } }
1363 sink: { type: jsonl, config: { path: ./out.jsonl } }
1364matrix:
1365 - id: users
1366 source: { config: { path: /v1/users } }
1367 sink: { config: { path: ./users.jsonl } }
1368 - id: posts
1369 parent: users
1370 parent_key: user_id
1371 source: { config: { path: "/v1/users/${users.id}/posts" } }
1372"#;
1373 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1374 assert_eq!(cfg.matrix.len(), 2);
1375 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
1376 assert!(cfg.matrix[0].parent.is_none());
1377 let users_src = cfg.matrix[0].source.as_ref().unwrap();
1378 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
1379
1380 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
1381 assert_eq!(cfg.matrix[1].parent_key, "user_id");
1382 }
1383
1384 #[test]
1385 fn parent_key_defaults_to_id() {
1386 let yaml = r#"
1387version: 1
1388pipeline:
1389 source: { type: rest, config: {} }
1390 sink: { type: jsonl, config: { path: ./o.jsonl } }
1391matrix:
1392 - { id: users }
1393 - { id: posts, parent: users }
1394"#;
1395 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1396 assert_eq!(cfg.matrix[1].parent_key, "id");
1397 }
1398
1399 #[test]
1400 fn parses_execution_block() {
1401 let yaml = r#"
1402version: 1
1403pipeline:
1404 source: { type: rest, config: {} }
1405 sink: { type: jsonl, config: { path: ./o.jsonl } }
1406execution:
1407 max_concurrent: 8
1408 on_error: stop
1409"#;
1410 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1411 let exec = cfg.execution.unwrap();
1412 assert_eq!(exec.max_concurrent, Some(8));
1413 assert_eq!(exec.on_error, OnError::Stop);
1414 }
1415
1416 #[test]
1417 fn on_error_defaults_to_continue() {
1418 let yaml = r#"
1419version: 1
1420pipeline:
1421 source: { type: rest, config: {} }
1422 sink: { type: jsonl, config: { path: ./o.jsonl } }
1423execution: { max_concurrent: 2 }
1424"#;
1425 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1426 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1427 }
1428
1429 #[test]
1430 fn rejects_old_top_level_source_sink_with_hint() {
1431 let yaml = r#"
1433version: 1
1434source: { type: rest, config: {} }
1435sink: { type: jsonl, config: { path: ./o.jsonl } }
1436"#;
1437 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1438 let msg = err.to_string();
1439 assert!(
1440 msg.contains("pipeline"),
1441 "expected a hint about wrapping in `pipeline:`, got: {msg}"
1442 );
1443 }
1444
1445 #[test]
1446 fn rejects_unknown_extension() {
1447 let text = "version: 1\n";
1448 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1449 assert!(matches!(err, CliError::UnknownExtension { .. }));
1450 }
1451
1452 #[test]
1453 fn rejects_future_version() {
1454 let yaml = r#"
1455version: 99
1456pipeline:
1457 source: { type: rest, config: {} }
1458 sink: { type: jsonl, config: { path: ./x } }
1459"#;
1460 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1461 match err {
1462 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1463 other => panic!("expected ParseConfig, got {other:?}"),
1464 }
1465 }
1466
1467 #[test]
1468 fn transforms_and_state_round_trip() {
1469 let yaml = r#"
1470version: 1
1471pipeline:
1472 source:
1473 type: rest
1474 config: {}
1475 transforms:
1476 - type: snake_case
1477 - type: flatten
1478 config: { separator: "__" }
1479 sink:
1480 type: jsonl
1481 config: { path: "./out.jsonl" }
1482 state:
1483 type: file
1484 config: { path: "./.faucet-state" }
1485"#;
1486 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1487 assert_eq!(cfg.pipeline.transforms.len(), 2);
1488 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1489 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1490 assert_eq!(
1491 cfg.pipeline.transforms[1].config,
1492 json!({"separator": "__"})
1493 );
1494 let state = cfg.pipeline.state.unwrap();
1495 assert_eq!(state.kind, "file");
1496 }
1497
1498 #[test]
1499 fn from_path_interpolates_env_var() {
1500 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1501 let dir = tempfile::tempdir().unwrap();
1502 let path = dir.path().join("pipeline.yaml");
1503 std::fs::write(
1504 &path,
1505 r#"
1506version: 1
1507pipeline:
1508 source:
1509 type: rest
1510 config:
1511 base_url: ${env:FAUCET_CFG_URL}
1512 sink:
1513 type: jsonl
1514 config:
1515 path: ./out.jsonl
1516"#,
1517 )
1518 .unwrap();
1519 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1520 assert_eq!(
1521 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1522 "https://x.example"
1523 );
1524 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1525 }
1526
1527 #[test]
1528 fn observability_block_parses() {
1529 let y = r#"
1530version: 1
1531name: x
1532observability:
1533 prometheus:
1534 listen: "127.0.0.1:9464"
1535 buckets: [0.01, 0.1, 1.0]
1536 tracing:
1537 level: "info"
1538pipeline:
1539 source:
1540 type: rest
1541 config:
1542 base_url: "https://example.com"
1543 path: "/data"
1544 sink:
1545 type: jsonl
1546 config:
1547 path: "/tmp/faucet-test.jsonl"
1548"#;
1549 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1550 let obs = cfg.observability.expect("observability block parsed");
1551 let p = obs.prometheus.expect("prometheus parsed");
1552 assert_eq!(p.listen, "127.0.0.1:9464");
1553 assert_eq!(p.buckets.unwrap().len(), 3);
1554 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1555 }
1556
1557 #[test]
1558 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1559 let dir = tempfile::tempdir().unwrap();
1562 let path = dir.path().join("pipeline.yaml");
1563 std::fs::write(
1564 &path,
1565 r#"
1566version: 1
1567pipeline:
1568 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1569 sink: { type: jsonl, config: { path: ./o.jsonl } }
1570"#,
1571 )
1572 .unwrap();
1573 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1574 assert_eq!(
1575 cfg.pipeline.source.as_ref().unwrap().config["path"],
1576 "/v1/users/${users.id}/posts"
1577 );
1578 }
1579
1580 #[cfg(feature = "schedule")]
1581 #[test]
1582 fn parses_schedule_block() {
1583 let yaml = r#"
1584version: 1
1585schedule:
1586 cron: "0 2 * * *"
1587 timezone: "America/Los_Angeles"
1588 overlap_policy: skip
1589 max_consecutive_failures: 5
1590pipeline:
1591 source: { type: rest, config: {} }
1592 sink: { type: jsonl, config: { path: ./o.jsonl } }
1593"#;
1594 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1595 let s = cfg.schedule.expect("schedule parsed");
1596 assert_eq!(s.cron, "0 2 * * *");
1597 assert_eq!(s.timezone, "America/Los_Angeles");
1598 assert_eq!(s.max_consecutive_failures, Some(5));
1599 }
1600
1601 #[test]
1602 fn execution_spec_parses_adaptive_block() {
1603 let yaml = r#"
1604version: 1
1605pipeline:
1606 source: { type: rest, config: { base_url: https://api.example.com } }
1607 sink: { type: jsonl, config: { path: ./out.jsonl } }
1608execution:
1609 adaptive_batch_size:
1610 enabled: true
1611 min: 200
1612 max: 4000
1613 target_latency_ms: 800
1614"#;
1615 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1616 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1617 assert!(ab.enabled);
1618 assert_eq!(ab.min, 200);
1619 assert_eq!(ab.target_latency_ms, Some(800));
1620 ab.validate().unwrap();
1621 }
1622
1623 #[cfg(feature = "quality")]
1624 #[test]
1625 fn parses_quality_block() {
1626 let yaml = r#"
1627version: 1
1628pipeline:
1629 source: { type: rest, config: { url: "https://x" } }
1630 quality:
1631 record:
1632 - { type: not_null, field: id, on_failure: abort }
1633 sink: { type: stdout, config: {} }
1634"#;
1635 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1636 let q = cfg.pipeline.quality.expect("quality parsed");
1637 assert_eq!(q.record.len(), 1);
1638 }
1639
1640 #[cfg(feature = "contract")]
1641 #[test]
1642 fn parses_contract_block() {
1643 let yaml = r#"
1644version: 1
1645pipeline:
1646 source: { type: rest, config: { url: "https://x" } }
1647 contract:
1648 version: "1.0.0"
1649 on_breach: warn
1650 fields:
1651 - { name: id, type: integer }
1652 - { name: status, type: string, enum: [open, closed] }
1653 sink: { type: stdout, config: {} }
1654"#;
1655 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1656 let c = cfg.pipeline.contract.expect("contract parsed");
1657 assert_eq!(c.version, "1.0.0");
1658 assert_eq!(c.on_breach, faucet_core::OnBreach::Warn);
1659 assert_eq!(c.fields.len(), 2);
1660 }
1661
1662 #[test]
1663 fn parses_dlq_block_with_defaults() {
1664 let yaml = r#"
1665version: 1
1666pipeline:
1667 source: { type: rest, config: {} }
1668 sink: { type: jsonl, config: { path: ./o.jsonl } }
1669 dlq:
1670 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1671"#;
1672 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1673 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1674 assert_eq!(dlq.sink.kind, "jsonl");
1675 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1676 assert!(dlq.max_failures_per_page.is_none());
1677 assert!(dlq.max_failures_total.is_none());
1678 assert!(dlq.include_original_payload);
1679 }
1680
1681 #[test]
1682 fn parses_dlq_block_with_dlq_all_and_budgets() {
1683 let yaml = r#"
1684version: 1
1685pipeline:
1686 source: { type: rest, config: {} }
1687 sink: { type: jsonl, config: { path: ./o.jsonl } }
1688 dlq:
1689 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1690 on_batch_error: dlq_all
1691 max_failures_per_page: 100
1692 max_failures_total: 10000
1693"#;
1694 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1695 let dlq = cfg.pipeline.dlq.unwrap();
1696 assert_eq!(dlq.sink.kind, "kafka");
1697 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1698 assert_eq!(dlq.max_failures_per_page, Some(100));
1699 assert_eq!(dlq.max_failures_total, Some(10000));
1700 }
1701
1702 #[test]
1703 fn matrix_row_dlq_null_disables_inherited_dlq() {
1704 let yaml = r#"
1705version: 1
1706pipeline:
1707 source: { type: rest, config: {} }
1708 sink: { type: jsonl, config: { path: ./o.jsonl } }
1709 dlq:
1710 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1711matrix:
1712 - id: a
1713 - id: b
1714 dlq: null
1715"#;
1716 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1717 assert!(cfg.matrix[0].dlq.is_none());
1718 assert_eq!(cfg.matrix[1].dlq, Some(None));
1719 }
1720
1721 #[test]
1722 fn matrix_row_dlq_object_replaces_inherited_dlq() {
1723 let yaml = r#"
1724version: 1
1725pipeline:
1726 source: { type: rest, config: {} }
1727 sink: { type: jsonl, config: { path: ./o.jsonl } }
1728 dlq:
1729 sink: { type: jsonl, config: { path: ./base.jsonl } }
1730matrix:
1731 - id: a
1732 dlq:
1733 sink: { type: jsonl, config: { path: ./a.jsonl } }
1734 on_batch_error: dlq_all
1735"#;
1736 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1737 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1738 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1739 let sink_path = row_dlq.sink.config.get("path").unwrap();
1740 assert_eq!(sink_path, "./a.jsonl");
1741 }
1742
1743 #[test]
1744 fn parses_named_sources_and_sinks() {
1745 let yaml = r#"
1746version: 1
1747pipeline:
1748 sources:
1749 users_api:
1750 type: rest
1751 config: { base_url: https://api.example.com }
1752 posts_api:
1753 type: rest
1754 config: { base_url: https://api.example.com }
1755 sinks:
1756 warehouse:
1757 type: postgres
1758 config: { connection_url: "postgres://x" }
1759"#;
1760 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1761 assert!(cfg.pipeline.source.is_none());
1762 assert!(cfg.pipeline.sink.is_none());
1763 assert_eq!(cfg.pipeline.sources.len(), 2);
1764 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1765 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1766 }
1767
1768 #[test]
1769 fn legacy_singular_source_still_parses() {
1770 let yaml = r#"
1771version: 1
1772pipeline:
1773 source: { type: rest, config: {} }
1774 sink: { type: jsonl, config: { path: ./o.jsonl } }
1775"#;
1776 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1777 assert!(cfg.pipeline.source.is_some());
1778 assert!(cfg.pipeline.sink.is_some());
1779 assert!(cfg.pipeline.sources.is_empty());
1780 assert!(cfg.pipeline.sinks.is_empty());
1781 }
1782
1783 #[test]
1784 fn parses_matrix_row_with_ref_field() {
1785 let yaml = r#"
1786version: 1
1787pipeline:
1788 source: { type: rest, config: {} }
1789 sink: { type: jsonl, config: { path: ./o.jsonl } }
1790matrix:
1791 - id: load_users
1792 source:
1793 ref: users_api
1794 config: { path: /v1/users }
1795"#;
1796 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1797 let src = cfg.matrix[0].source.as_ref().unwrap();
1798 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1799 assert_eq!(src.kind, None);
1800 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1801 }
1802
1803 #[test]
1804 fn parses_top_level_vars_block() {
1805 let yaml = r#"
1806version: 1
1807vars:
1808 api_base: https://api.example.com
1809 api_token_env: API_TOKEN
1810pipeline:
1811 source: { type: rest, config: {} }
1812 sink: { type: jsonl, config: { path: ./o.jsonl } }
1813"#;
1814 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1815 let vars = cfg.vars.as_ref().unwrap();
1816 assert_eq!(vars["api_base"], "https://api.example.com");
1817 assert_eq!(vars["api_token_env"], "API_TOKEN");
1818 }
1819
1820 #[test]
1821 fn vars_block_is_optional() {
1822 let yaml = r#"
1823version: 1
1824pipeline:
1825 source: { type: rest, config: {} }
1826 sink: { type: jsonl, config: { path: ./o.jsonl } }
1827"#;
1828 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1829 assert!(cfg.vars.is_none());
1830 }
1831
1832 #[test]
1833 fn from_path_resolves_vars_at_load() {
1834 let dir = tempfile::tempdir().unwrap();
1835 let path = dir.path().join("pipeline.yaml");
1836 std::fs::write(
1837 &path,
1838 r#"
1839version: 1
1840vars:
1841 base: https://api.example.com
1842pipeline:
1843 source: { type: rest, config: { url: "${vars.base}/v1" } }
1844 sink: { type: jsonl, config: { path: ./o.jsonl } }
1845"#,
1846 )
1847 .unwrap();
1848 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1849 assert_eq!(
1850 cfg.pipeline.source.as_ref().unwrap().config["url"],
1851 "https://api.example.com/v1"
1852 );
1853 }
1854
1855 #[test]
1856 fn sync_from_path_errors_on_secret_directive() {
1857 let dir = tempfile::tempdir().unwrap();
1858 let path = dir.path().join("p.yaml");
1859 std::fs::write(
1860 &path,
1861 r#"
1862version: 1
1863pipeline:
1864 source: { type: rest, config: { url: "${vault:secret/x}" } }
1865 sink: { type: jsonl, config: { path: ./o.jsonl } }
1866"#,
1867 )
1868 .unwrap();
1869 match PipelineConfig::from_path(&path, None).unwrap_err() {
1870 CliError::SecretsRequireAsyncLoad => {}
1871 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1872 }
1873 }
1874
1875 #[test]
1876 fn from_value_accepts_v1_and_resolves_refs() {
1877 let v = serde_json::json!({
1878 "version": 1,
1879 "vars": { "out": "resolved.jsonl" },
1880 "pipeline": {
1881 "source": { "type": "csv", "config": { "path": "x.csv" } },
1882 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1883 }
1884 });
1885 let cfg = PipelineConfig::from_value(v).unwrap();
1886 assert_eq!(cfg.version, 1);
1887 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1889 }
1890
1891 #[test]
1892 fn from_value_rejects_non_v1() {
1893 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1895 let err = PipelineConfig::from_value(v).unwrap_err();
1896 match err {
1897 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1898 other => panic!("expected ParseConfig, got {other:?}"),
1899 }
1900 }
1901
1902 #[tokio::test]
1903 async fn async_from_path_loads_without_secrets() {
1904 let dir = tempfile::tempdir().unwrap();
1905 let path = dir.path().join("p.yaml");
1906 std::fs::write(
1907 &path,
1908 r#"
1909version: 1
1910pipeline:
1911 source: { type: rest, config: { base_url: https://x } }
1912 sink: { type: jsonl, config: { path: ./o.jsonl } }
1913"#,
1914 )
1915 .unwrap();
1916 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1917 assert_eq!(cfg.version, 1);
1918 }
1919
1920 #[cfg(feature = "lineage")]
1921 #[test]
1922 fn parses_lineage_block() {
1923 let yaml = r#"
1924version: 1
1925lineage:
1926 namespace: prod
1927 transport: { type: file, config: { path: /tmp/ol.jsonl } }
1928pipeline:
1929 source: { type: rest, config: {} }
1930 sink: { type: jsonl, config: { path: ./o.jsonl } }
1931"#;
1932 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1933 let l = cfg.lineage.expect("lineage parsed");
1934 assert_eq!(l.namespace, "prod");
1935 }
1936
1937 #[test]
1938 fn from_path_resolves_extends_and_profile() {
1939 let dir = tempfile::tempdir().unwrap();
1940 std::fs::write(
1941 dir.path().join("base.yaml"),
1942 "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",
1943 )
1944 .unwrap();
1945 let app = dir.path().join("app.yaml");
1946 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1947
1948 let cfg = PipelineConfig::from_path(&app, None).unwrap();
1950 assert_eq!(
1951 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1952 "base.jsonl"
1953 );
1954
1955 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1957 assert_eq!(
1958 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1959 "prod.jsonl"
1960 );
1961 }
1962
1963 #[test]
1964 fn from_value_rejects_extends_with_composition_hint() {
1965 let v = serde_json::json!({
1967 "version": 1,
1968 "extends": "base.yaml",
1969 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1970 });
1971 let err = PipelineConfig::from_value(v).unwrap_err();
1972 let msg = err.to_string();
1973 assert!(
1974 msg.contains("composition"),
1975 "expected composition hint, got: {msg}"
1976 );
1977 }
1978
1979 #[test]
1980 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1981 let yaml = r#"
1983version: 1
1984pipeline:
1985 source: { type: rest, config: {} }
1986 sink: { type: jsonl, config: { path: ./o.jsonl } }
1987"#;
1988 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1989 assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1990
1991 let yaml2 = r#"
1993version: 1
1994delivery: exactly_once
1995pipeline:
1996 source: { type: rest, config: {} }
1997 sink: { type: jsonl, config: { path: ./o.jsonl } }
1998"#;
1999 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
2000 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
2001
2002 let yaml3 = r#"
2004version: 1
2005delivery: at_least_once
2006pipeline:
2007 source: { type: rest, config: {} }
2008 sink: { type: jsonl, config: { path: ./o.jsonl } }
2009matrix:
2010 - id: a
2011 - id: b
2012 delivery: exactly_once
2013"#;
2014 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
2015 assert_eq!(cfg3.matrix[0].delivery, None);
2016 assert_eq!(
2017 cfg3.matrix[1].delivery,
2018 Some(faucet_core::DeliveryMode::ExactlyOnce)
2019 );
2020 }
2021
2022 #[test]
2023 fn resilience_spec_parses_and_builds_policy() {
2024 let yaml = r#"
2025version: 1
2026pipeline:
2027 source: { type: rest, config: { base_url: "https://x" } }
2028 sink: { type: stdout, config: {} }
2029resilience:
2030 retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
2031 retry_on: [http_5xx, timeout]
2032 circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
2033 poison: { max_row_attempts: 2, action: dlq }
2034"#;
2035 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2036 let spec = cfg.resilience.unwrap();
2037 let policy = spec.to_policy().unwrap();
2038 assert_eq!(policy.retry.max_attempts, 4);
2039 assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
2040 assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
2041 }
2042
2043 #[test]
2044 fn resilience_rejects_zero_max_attempts() {
2045 let yaml = r#"
2046version: 1
2047pipeline:
2048 source: { type: rest, config: { base_url: "https://x" } }
2049 sink: { type: stdout, config: {} }
2050resilience: { retry: { max_attempts: 0 } }
2051"#;
2052 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2053 let err = cfg.resilience.unwrap().to_policy().unwrap_err();
2054 assert!(err.to_string().contains("max_attempts"));
2055 }
2056
2057 #[test]
2058 fn observability_parses_otel_block() {
2059 let yaml = r#"
2060version: 1
2061pipeline:
2062 source: { type: rest, config: { base_url: "http://x" } }
2063 sink: { type: stdout, config: {} }
2064observability:
2065 otel:
2066 endpoint: http://collector:4317
2067 protocol: grpc
2068 export: [traces, metrics]
2069"#;
2070 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2071 let otel = cfg.observability.unwrap().otel.unwrap();
2072 assert_eq!(otel.endpoint, "http://collector:4317");
2073 }
2074
2075 #[test]
2076 fn otel_validation_rejects_bad_ratio() {
2077 let yaml = r#"
2078version: 1
2079pipeline:
2080 source: { type: rest, config: { base_url: "http://x" } }
2081 sink: { type: stdout, config: {} }
2082observability:
2083 otel:
2084 sample_ratio: 9.0
2085"#;
2086 let err = parse_with_extension(yaml, "yaml").unwrap_err();
2087 assert!(format!("{err}").contains("sample_ratio"));
2088 }
2089}