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")]
123 pub reconcile: Option<crate::reconcile::ReconcileSpec>,
124
125 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub shard: Option<ShardingSpec>,
133
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub replication: Option<crate::replication::spec::ReplicationSpec>,
138
139 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub backfill: Option<crate::backfill::BackfillSpec>,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub partition: Option<crate::partition::PartitionSpec>,
150
151 #[cfg(feature = "schedule")]
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
156
157 #[cfg(feature = "lineage")]
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub lineage: Option<faucet_lineage::LineageConfig>,
161
162 #[cfg(feature = "catalog")]
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub catalog: Option<crate::catalog::CatalogSpec>,
170
171 #[cfg(feature = "notify")]
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub notifications: Vec<crate::notify::NotificationSpec>,
179
180 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub metadata_columns: Option<faucet_core::MetadataColumnsSpec>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
191#[serde(deny_unknown_fields)]
192pub struct PipelineSpec {
193 #[serde(default)]
196 pub source: Option<ConnectorSpec>,
197
198 #[serde(default)]
200 pub sink: Option<ConnectorSpec>,
201
202 #[serde(default)]
204 pub sources: HashMap<String, ConnectorSpec>,
205
206 #[serde(default)]
208 pub sinks: HashMap<String, ConnectorSpec>,
209
210 #[serde(default)]
211 pub transforms: Vec<TransformSpec>,
212 #[serde(default)]
213 pub state: Option<StateStoreSpec>,
214 #[serde(default)]
215 pub dlq: Option<DlqSpec>,
216
217 #[cfg(feature = "quality")]
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub quality: Option<faucet_core::QualitySpec>,
221
222 #[cfg(feature = "contract")]
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub contract: Option<faucet_core::ContractSpec>,
228
229 #[cfg(feature = "masking")]
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub masking: Option<faucet_core::MaskingSpec>,
238
239 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub schema: Option<faucet_core::SchemaDriftSpec>,
242
243 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
248 pub nodes: HashMap<String, NodeSpec>,
249
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
254 pub edges: Vec<EdgeSpec>,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
266#[serde(tag = "kind", rename_all = "lowercase")]
267pub enum NodeSpec {
268 Source {
270 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
272 template: Option<String>,
273 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
275 kind: Option<String>,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 config: Option<Value>,
279 },
280 Sink {
282 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
284 template: Option<String>,
285 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
287 kind: Option<String>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 config: Option<Value>,
291 },
292 Transform {
294 #[serde(default)]
296 transforms: Vec<TransformSpec>,
297 },
298 Tee {
300 #[serde(default = "default_channel_capacity")]
302 channel_capacity: usize,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 fanout: Option<usize>,
306 },
307 Merge,
309 Join(JoinSpec),
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
315#[serde(deny_unknown_fields)]
316pub struct JoinSpec {
317 #[serde(default)]
319 pub mode: faucet_core::JoinMode,
320 pub build: JoinSide,
322 pub probe: JoinSide,
324 #[serde(default)]
326 pub project: Vec<faucet_core::Projection>,
327 #[serde(default)]
329 pub on_missing: Value,
330 #[serde(default)]
332 pub on_duplicate: faucet_core::OnDuplicate,
333 #[serde(default)]
335 pub on_collision: faucet_core::OnCollision,
336 #[serde(default)]
338 pub key_normalize: faucet_core::KeyNormalize,
339 #[serde(default = "default_max_build_records")]
341 pub max_build_records: usize,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
346#[serde(deny_unknown_fields)]
347pub struct JoinSide {
348 pub edge: String,
350 pub key: String,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
356#[serde(deny_unknown_fields)]
357pub struct EdgeSpec {
358 pub from: String,
360 pub to: String,
362 #[serde(rename = "as", default, skip_serializing_if = "Option::is_none")]
364 pub label: Option<String>,
365}
366
367fn default_channel_capacity() -> usize {
368 faucet_core::topology::DEFAULT_CHANNEL_CAPACITY
369}
370
371fn default_max_build_records() -> usize {
372 faucet_core::join::DEFAULT_MAX_BUILD_RECORDS
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
380#[serde(deny_unknown_fields)]
381pub struct ConnectorSpec {
382 #[serde(rename = "type")]
385 pub kind: String,
386
387 #[serde(default = "empty_object")]
390 pub config: Value,
391
392 #[serde(default)]
396 pub transforms: Option<Vec<TransformSpec>>,
397
398 #[serde(default = "default_true")]
402 pub inherit_transforms: bool,
403
404 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub status: Option<SourceStatus>,
410
411 #[serde(default, skip_serializing_if = "Vec::is_empty")]
416 pub tags: Vec<String>,
417
418 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub complete_for: Option<CompletenessClaim>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
431#[serde(deny_unknown_fields)]
432pub struct CompletenessClaim {
433 pub scope: std::collections::BTreeMap<String, Value>,
439
440 #[serde(default)]
444 pub on_missing: OnMissing,
445}
446
447#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
450#[serde(rename_all = "snake_case")]
451pub enum OnMissing {
452 #[default]
454 Ignore,
455 Delete,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
467#[serde(deny_unknown_fields)]
468pub struct PartialConnector {
469 #[serde(default)]
472 pub r#ref: Option<String>,
473 #[serde(rename = "type", default)]
475 pub kind: Option<String>,
476 #[serde(default)]
478 pub config: Option<Value>,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub status: Option<SourceStatus>,
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
488#[serde(deny_unknown_fields)]
489pub struct TransformSpec {
490 #[serde(rename = "type")]
495 pub kind: String,
496
497 #[serde(default = "empty_object")]
499 pub config: Value,
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
504#[serde(deny_unknown_fields)]
505pub struct StateStoreSpec {
506 #[serde(rename = "type")]
508 pub kind: String,
509
510 #[serde(default = "empty_object")]
512 pub config: Value,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
517#[serde(deny_unknown_fields)]
518pub struct MatrixRow {
519 #[serde(default)]
522 pub id: Option<String>,
523
524 #[serde(default)]
526 pub parent: Option<String>,
527
528 #[serde(default)]
533 pub depends_on: Vec<String>,
534
535 #[serde(default = "default_parent_key")]
538 pub parent_key: String,
539
540 #[serde(default)]
542 pub source: Option<PartialConnector>,
543
544 #[serde(default)]
546 pub sink: Option<PartialConnector>,
547
548 #[serde(default)]
553 pub transforms: Option<Vec<TransformSpec>>,
554
555 #[serde(default = "default_true")]
558 pub inherit_transforms: bool,
559
560 #[serde(default)]
562 pub state: Option<StateStoreSpec>,
563
564 #[serde(default, deserialize_with = "deserialize_dlq_override")]
569 pub dlq: Option<Option<DlqSpec>>,
570
571 #[serde(default)]
573 pub delivery: Option<faucet_core::DeliveryMode>,
574
575 #[serde(default, skip_serializing_if = "Vec::is_empty")]
582 pub tags: Vec<String>,
583
584 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub partition: Option<crate::partition::PartitionSpec>,
589
590 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub discover: Option<DiscoverSpec>,
597
598 #[serde(default, skip_serializing_if = "Vec::is_empty")]
605 pub for_each: Vec<String>,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
610#[serde(deny_unknown_fields)]
611pub struct DiscoverSpec {
612 pub source: PartialConnector,
619
620 pub select: String,
624
625 #[serde(rename = "as")]
628 pub as_alias: String,
629
630 #[serde(default)]
638 pub collect: bool,
639}
640
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
646#[serde(rename_all = "snake_case")]
647pub enum SourceStatus {
648 Mandatory,
650 #[default]
652 Active,
653 Available,
655 Draft,
657 Archived,
659}
660
661impl SourceStatus {
662 pub const ALL: [SourceStatus; 5] = [
664 SourceStatus::Mandatory,
665 SourceStatus::Active,
666 SourceStatus::Available,
667 SourceStatus::Draft,
668 SourceStatus::Archived,
669 ];
670
671 pub fn as_str(self) -> &'static str {
673 match self {
674 SourceStatus::Mandatory => "mandatory",
675 SourceStatus::Active => "active",
676 SourceStatus::Available => "available",
677 SourceStatus::Draft => "draft",
678 SourceStatus::Archived => "archived",
679 }
680 }
681
682 pub fn parse(s: &str) -> Option<Self> {
685 SourceStatus::ALL.into_iter().find(|v| v.as_str() == s)
686 }
687
688 pub fn default_eligible(self) -> bool {
690 matches!(self, SourceStatus::Mandatory | SourceStatus::Active)
691 }
692}
693
694#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
700#[serde(rename_all = "snake_case")]
701pub enum IncludeParents {
702 #[default]
705 Off,
706 Eligible,
709 All,
712}
713
714impl IncludeParents {
715 pub fn as_str(self) -> &'static str {
717 match self {
718 IncludeParents::Off => "off",
719 IncludeParents::Eligible => "eligible",
720 IncludeParents::All => "all",
721 }
722 }
723
724 pub fn parse(s: &str) -> Option<Self> {
726 match s {
727 "off" => Some(IncludeParents::Off),
728 "eligible" => Some(IncludeParents::Eligible),
729 "all" => Some(IncludeParents::All),
730 _ => None,
731 }
732 }
733}
734
735#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
738#[serde(deny_unknown_fields)]
739pub struct SelectionSpec {
740 #[serde(default)]
742 pub include_parents: IncludeParents,
743}
744
745#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
747#[serde(deny_unknown_fields)]
748pub struct ExecutionSpec {
749 #[serde(default)]
753 pub max_concurrent: Option<usize>,
754
755 #[serde(default)]
757 pub on_error: OnError,
758
759 #[serde(default)]
761 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
766#[serde(deny_unknown_fields)]
767pub struct ShardingSpec {
768 pub count: usize,
773}
774
775#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
777#[serde(rename_all = "lowercase")]
778pub enum OnError {
779 #[default]
781 Continue,
782 Stop,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
788pub struct ObservabilitySpec {
789 #[serde(default)]
791 pub prometheus: Option<PrometheusSpec>,
792
793 #[serde(default)]
795 pub tracing: Option<TracingSpec>,
796
797 #[serde(default)]
799 pub otel: Option<OtelSpec>,
800}
801
802#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
804pub struct PrometheusSpec {
805 pub listen: String,
807
808 #[serde(default)]
811 pub buckets: Option<Vec<f64>>,
812}
813
814#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
816pub struct TracingSpec {
817 #[serde(default)]
820 pub level: Option<String>,
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
825pub struct OtelSpec {
826 #[serde(default)]
829 pub endpoint: String,
830 #[serde(default)]
832 pub protocol: faucet_core::OtelProtocol,
833 #[serde(default)]
835 pub headers: std::collections::HashMap<String, String>,
836 #[serde(default = "default_otel_ratio")]
838 pub sample_ratio: f64,
839 #[serde(default = "default_otel_export")]
841 pub export: Vec<faucet_core::OtelSignal>,
842 #[serde(default = "default_otel_service")]
844 pub service_name: String,
845 #[serde(default = "default_otel_timeout")]
847 pub timeout_secs: u64,
848 #[serde(default = "default_otel_interval")]
850 pub metric_interval_secs: u64,
851}
852
853fn default_otel_ratio() -> f64 {
854 1.0
855}
856fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
857 vec![
858 faucet_core::OtelSignal::Traces,
859 faucet_core::OtelSignal::Metrics,
860 ]
861}
862fn default_otel_service() -> String {
863 "faucet".to_string()
864}
865fn default_otel_timeout() -> u64 {
866 10
867}
868fn default_otel_interval() -> u64 {
869 60
870}
871
872impl OtelSpec {
873 pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
875 let cfg = faucet_core::OtelConfig {
876 endpoint: self.endpoint.clone(),
877 protocol: self.protocol,
878 headers: self.headers.clone(),
879 sample_ratio: self.sample_ratio,
880 export: self.export.clone(),
881 service_name: self.service_name.clone(),
882 timeout_secs: self.timeout_secs,
883 metric_interval_secs: self.metric_interval_secs,
884 };
885 cfg.validate()?;
886 Ok(cfg)
887 }
888}
889
890#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
894#[serde(rename_all = "snake_case")]
895pub enum OnBatchErrorSpec {
896 #[default]
897 Propagate,
898 DlqAll,
899}
900
901#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
903#[serde(deny_unknown_fields)]
904pub struct DlqSpec {
905 pub sink: ConnectorSpec,
906 #[serde(default)]
907 pub on_batch_error: OnBatchErrorSpec,
908 #[serde(default)]
909 pub max_failures_per_page: Option<usize>,
910 #[serde(default)]
911 pub max_failures_total: Option<usize>,
912 #[serde(default = "default_true")]
913 pub include_original_payload: bool,
914}
915
916#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
918#[serde(deny_unknown_fields)]
919pub struct ResilienceSpec {
920 #[serde(default)]
923 pub retry: RetrySpec,
924 #[serde(default)]
926 pub retry_on: Option<Vec<faucet_core::RetryClass>>,
927 #[serde(default)]
929 pub circuit_breaker: Option<CircuitBreakerSpec>,
930 #[serde(default)]
932 pub poison: Option<PoisonSpec>,
933}
934
935#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
937#[serde(deny_unknown_fields)]
938pub struct RetrySpec {
939 #[serde(default = "default_max_attempts")]
941 pub max_attempts: u32,
942 #[serde(default)]
944 pub backoff: BackoffSpec,
945 #[serde(default = "default_base_ms")]
947 pub base_ms: u64,
948 #[serde(default = "default_max_ms")]
950 pub max_ms: u64,
951 #[serde(default = "default_true")]
953 pub jitter: bool,
954}
955
956impl Default for RetrySpec {
957 fn default() -> Self {
958 Self {
959 max_attempts: default_max_attempts(),
960 backoff: BackoffSpec::default(),
961 base_ms: default_base_ms(),
962 max_ms: default_max_ms(),
963 jitter: true,
964 }
965 }
966}
967
968#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
970#[serde(rename_all = "snake_case")]
971pub enum BackoffSpec {
972 None,
974 Fixed,
976 #[default]
978 Exponential,
979}
980
981#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
983#[serde(deny_unknown_fields)]
984pub struct CircuitBreakerSpec {
985 pub consecutive_failures: u32,
987 pub cooldown_secs: u64,
989}
990
991#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
993#[serde(deny_unknown_fields)]
994pub struct PoisonSpec {
995 pub max_row_attempts: u32,
997 #[serde(default)]
999 pub action: PoisonActionSpec,
1000}
1001
1002#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1005#[serde(rename_all = "snake_case")]
1006pub enum PoisonActionSpec {
1007 #[default]
1009 Dlq,
1010 Drop,
1012 Fail,
1014}
1015
1016fn default_max_attempts() -> u32 {
1017 5
1018}
1019fn default_base_ms() -> u64 {
1020 200
1021}
1022fn default_max_ms() -> u64 {
1023 30_000
1024}
1025
1026impl ResilienceSpec {
1027 pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
1029 use crate::error::CliError;
1030 if self.retry.max_attempts < 1 {
1031 return Err(CliError::Config(
1032 "resilience.retry.max_attempts must be >= 1".into(),
1033 ));
1034 }
1035 if self.retry.base_ms > self.retry.max_ms {
1036 return Err(CliError::Config(
1037 "resilience.retry.base_ms must be <= max_ms".into(),
1038 ));
1039 }
1040 let retry_on = match &self.retry_on {
1041 Some(v) if v.is_empty() => {
1042 return Err(CliError::Config(
1043 "resilience.retry_on must not be empty".into(),
1044 ));
1045 }
1046 Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
1047 None => faucet_core::RetryClassSet::default(),
1048 };
1049 let backoff = match self.retry.backoff {
1050 BackoffSpec::None => faucet_core::BackoffKind::None,
1051 BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
1052 BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
1053 };
1054 let circuit_breaker = match self.circuit_breaker {
1055 Some(cb) if cb.consecutive_failures < 1 => {
1056 return Err(CliError::Config(
1057 "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
1058 ));
1059 }
1060 Some(cb) => Some(faucet_core::CircuitBreakerConfig {
1061 consecutive_failures: cb.consecutive_failures,
1062 cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
1063 }),
1064 None => None,
1065 };
1066 let poison = match self.poison {
1067 Some(p) if p.max_row_attempts < 1 => {
1068 return Err(CliError::Config(
1069 "resilience.poison.max_row_attempts must be >= 1".into(),
1070 ));
1071 }
1072 Some(p) => Some(faucet_core::PoisonPolicy {
1073 max_row_attempts: p.max_row_attempts,
1074 action: match p.action {
1075 PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
1076 PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
1077 PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
1078 },
1079 }),
1080 None => None,
1081 };
1082 Ok(faucet_core::ResiliencePolicy {
1083 retry: faucet_core::RetryPolicy {
1084 max_attempts: self.retry.max_attempts,
1085 backoff,
1086 base: std::time::Duration::from_millis(self.retry.base_ms),
1087 max: std::time::Duration::from_millis(self.retry.max_ms),
1088 jitter: self.retry.jitter,
1089 retry_on,
1090 },
1091 circuit_breaker,
1092 poison,
1093 })
1094 }
1095}
1096
1097fn default_true() -> bool {
1098 true
1099}
1100
1101fn default_version() -> u32 {
1102 1
1103}
1104fn default_parent_key() -> String {
1105 "id".to_owned()
1106}
1107fn empty_object() -> Value {
1108 Value::Object(Default::default())
1109}
1110
1111fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
1112where
1113 D: serde::Deserializer<'de>,
1114{
1115 Option::<DlqSpec>::deserialize(deserializer).map(Some)
1116}
1117
1118#[derive(Debug, Clone)]
1138pub struct RunInputs {
1139 pub params: crate::params::SuppliedParams,
1142 pub env: crate::interpolate::EnvOverlay,
1145 pub mode: crate::params::BindMode,
1147}
1148
1149impl Default for RunInputs {
1150 fn default() -> Self {
1151 Self {
1152 params: Default::default(),
1153 env: Default::default(),
1154 mode: crate::params::BindMode::Strict,
1155 }
1156 }
1157}
1158
1159impl RunInputs {
1160 pub fn placeholders() -> Self {
1163 Self {
1164 mode: crate::params::BindMode::Placeholder,
1165 ..Self::default()
1166 }
1167 }
1168
1169 pub fn with_params(params: crate::params::SuppliedParams) -> Self {
1171 Self {
1172 params,
1173 ..Self::default()
1174 }
1175 }
1176}
1177
1178fn resolve_document(text: &str, path: &Path, inputs: &RunInputs) -> CliResult<String> {
1179 use crate::interpolate::interpolate_value_with_env;
1180 let ext = path
1181 .extension()
1182 .and_then(|e| e.to_str())
1183 .map(str::to_ascii_lowercase);
1184 let resolve = |value: &mut serde_json::Value| -> CliResult<()> {
1189 interpolate_value_with_env(value, &inputs.env)?;
1190 crate::params::bind_document(value, &inputs.params, inputs.mode)?;
1191 Ok(())
1192 };
1193 match ext.as_deref() {
1194 Some("yaml" | "yml") => {
1195 let mut value: serde_json::Value =
1196 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1197 path: path.to_path_buf(),
1198 message: friendly_parse_error(&e.to_string()),
1199 })?;
1200 resolve(&mut value)?;
1201 serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
1202 path: path.to_path_buf(),
1203 message: e.to_string(),
1204 })
1205 }
1206 Some("json") => {
1207 let mut value: serde_json::Value =
1208 serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1209 path: path.to_path_buf(),
1210 message: friendly_parse_error(&e.to_string()),
1211 })?;
1212 resolve(&mut value)?;
1213 serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
1214 path: path.to_path_buf(),
1215 message: e.to_string(),
1216 })
1217 }
1218 _ => Err(CliError::UnknownExtension {
1219 path: path.to_path_buf(),
1220 }),
1221 }
1222}
1223
1224impl PipelineConfig {
1225 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1238 Self::from_path_with(path, profile, &RunInputs::default())
1239 }
1240
1241 pub fn from_path_with(
1244 path: impl AsRef<Path>,
1245 profile: Option<&str>,
1246 inputs: &RunInputs,
1247 ) -> CliResult<Self> {
1248 let path = path.as_ref();
1249 let composed = crate::compose::compose(path, profile)?;
1250 let interpolated = resolve_document(&composed, path, inputs)?;
1251 let cfg = Self::from_text(&interpolated, path)?;
1252 crate::secrets::ensure_no_secret_directives(&cfg)?;
1255 Ok(cfg)
1256 }
1257
1258 pub fn from_path_tolerating_secrets(
1262 path: impl AsRef<Path>,
1263 profile: Option<&str>,
1264 ) -> CliResult<Self> {
1265 Self::from_path_tolerating_secrets_with(path, profile, &RunInputs::default())
1266 }
1267
1268 pub fn from_path_tolerating_secrets_with(
1270 path: impl AsRef<Path>,
1271 profile: Option<&str>,
1272 inputs: &RunInputs,
1273 ) -> CliResult<Self> {
1274 let path = path.as_ref();
1275 let composed = crate::compose::compose(path, profile)?;
1276 let interpolated = resolve_document(&composed, path, inputs)?;
1277 Self::from_text(&interpolated, path)
1278 }
1279
1280 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1284 Self::from_path_async_with(path, profile, &RunInputs::default()).await
1285 }
1286
1287 pub async fn from_path_async_with(
1290 path: impl AsRef<Path>,
1291 profile: Option<&str>,
1292 inputs: &RunInputs,
1293 ) -> CliResult<Self> {
1294 let path = path.as_ref();
1295 let composed = crate::compose::compose(path, profile)?;
1296 let interpolated = resolve_document(&composed, path, inputs)?;
1297 let mut cfg = Self::from_text(&interpolated, path)?;
1298 crate::secrets::resolve_secrets(&mut cfg).await?;
1299 Ok(cfg)
1300 }
1301
1302 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
1305 let ext = path
1306 .extension()
1307 .and_then(|e| e.to_str())
1308 .map(str::to_ascii_lowercase);
1309 let cfg: PipelineConfig = match ext.as_deref() {
1310 Some("yaml" | "yml") => {
1311 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1312 path: path.to_path_buf(),
1313 message: friendly_parse_error(&e.to_string()),
1314 })?
1315 }
1316 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1317 path: path.to_path_buf(),
1318 message: friendly_parse_error(&e.to_string()),
1319 })?,
1320 _ => {
1321 return Err(CliError::UnknownExtension {
1322 path: path.to_path_buf(),
1323 });
1324 }
1325 };
1326 Self::finish(cfg, path)
1327 }
1328
1329 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
1337 let synthetic = Path::new("<submitted>");
1338 let cfg: PipelineConfig =
1339 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
1340 path: synthetic.to_path_buf(),
1341 message: friendly_parse_error(&e.to_string()),
1342 })?;
1343 Self::finish(cfg, synthetic)
1344 }
1345
1346 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
1348 if cfg.version != 1 {
1349 return Err(CliError::ParseConfig {
1350 path: path.to_path_buf(),
1351 message: format!(
1352 "unsupported pipeline version {}, only version 1 is recognised",
1353 cfg.version
1354 ),
1355 });
1356 }
1357 crate::interpolate::resolve_config_refs(&mut cfg)?;
1358 if let Some(obs) = cfg.observability.as_ref()
1359 && let Some(otel) = obs.otel.as_ref()
1360 {
1361 otel.to_core().map_err(CliError::Config)?;
1362 }
1363 Ok(cfg)
1364 }
1365}
1366
1367fn friendly_parse_error(raw: &str) -> String {
1370 let lower = raw.to_ascii_lowercase();
1371 if lower.contains("missing field `pipeline`") {
1372 return format!(
1373 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
1374 );
1375 }
1376 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
1377 return format!(
1378 "{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."
1379 );
1380 }
1381 raw.to_owned()
1382}
1383
1384pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
1387 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
1388 PipelineConfig::from_text(text, &synthetic)
1389}
1390
1391#[cfg(test)]
1392mod tests {
1393 use super::*;
1394 use serde_json::json;
1395
1396 #[test]
1397 fn parses_minimal_pipeline_yaml() {
1398 let yaml = r#"
1399version: 1
1400pipeline:
1401 source:
1402 type: rest
1403 config:
1404 base_url: https://api.example.com
1405 sink:
1406 type: jsonl
1407 config:
1408 path: ./out.jsonl
1409"#;
1410 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1411 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1412 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
1413 assert!(cfg.matrix.is_empty());
1414 assert!(cfg.execution.is_none());
1415 assert!(cfg.pipeline.transforms.is_empty());
1416 assert!(cfg.pipeline.state.is_none());
1417 }
1418
1419 #[test]
1420 fn parses_replication_block() {
1421 let yaml = r#"
1422version: 1
1423pipeline:
1424 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
1425 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1426 state: { type: file, config: { path: ./st } }
1427replication:
1428 mode: snapshot_then_cdc
1429 snapshot:
1430 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
1431"#;
1432 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1433 let r = cfg.replication.expect("replication parsed");
1434 assert_eq!(r.snapshot.source.kind, "postgres");
1435 }
1436
1437 #[test]
1438 fn pipeline_spec_parses_schema_block() {
1439 let yaml = r#"
1440version: 1
1441pipeline:
1442 source:
1443 type: rest
1444 config:
1445 base_url: https://api.example.com
1446 sink:
1447 type: jsonl
1448 config:
1449 path: ./out.jsonl
1450 schema:
1451 on_drift: evolve
1452 allow_type_widening: false
1453"#;
1454 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1455 let schema = cfg.pipeline.schema.expect("schema block parsed");
1456 assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
1457 assert!(!schema.allow_type_widening);
1458 }
1459
1460 #[test]
1461 fn parses_minimal_json() {
1462 let raw = r#"{
1463 "version": 1,
1464 "pipeline": {
1465 "source": {"type": "rest", "config": {}},
1466 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
1467 }
1468 }"#;
1469 let cfg = parse_with_extension(raw, "json").unwrap();
1470 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1471 }
1472
1473 #[test]
1474 fn parses_matrix_rows_with_partial_overrides() {
1475 let yaml = r#"
1476version: 1
1477pipeline:
1478 source: { type: rest, config: { base_url: https://api.example.com } }
1479 sink: { type: jsonl, config: { path: ./out.jsonl } }
1480matrix:
1481 - id: users
1482 source: { config: { path: /v1/users } }
1483 sink: { config: { path: ./users.jsonl } }
1484 - id: posts
1485 parent: users
1486 parent_key: user_id
1487 source: { config: { path: "/v1/users/${users.id}/posts" } }
1488"#;
1489 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1490 assert_eq!(cfg.matrix.len(), 2);
1491 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
1492 assert!(cfg.matrix[0].parent.is_none());
1493 let users_src = cfg.matrix[0].source.as_ref().unwrap();
1494 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
1495
1496 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
1497 assert_eq!(cfg.matrix[1].parent_key, "user_id");
1498 }
1499
1500 #[test]
1501 fn parent_key_defaults_to_id() {
1502 let yaml = r#"
1503version: 1
1504pipeline:
1505 source: { type: rest, config: {} }
1506 sink: { type: jsonl, config: { path: ./o.jsonl } }
1507matrix:
1508 - { id: users }
1509 - { id: posts, parent: users }
1510"#;
1511 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1512 assert_eq!(cfg.matrix[1].parent_key, "id");
1513 }
1514
1515 #[test]
1516 fn parses_execution_block() {
1517 let yaml = r#"
1518version: 1
1519pipeline:
1520 source: { type: rest, config: {} }
1521 sink: { type: jsonl, config: { path: ./o.jsonl } }
1522execution:
1523 max_concurrent: 8
1524 on_error: stop
1525"#;
1526 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1527 let exec = cfg.execution.unwrap();
1528 assert_eq!(exec.max_concurrent, Some(8));
1529 assert_eq!(exec.on_error, OnError::Stop);
1530 }
1531
1532 #[test]
1533 fn on_error_defaults_to_continue() {
1534 let yaml = r#"
1535version: 1
1536pipeline:
1537 source: { type: rest, config: {} }
1538 sink: { type: jsonl, config: { path: ./o.jsonl } }
1539execution: { max_concurrent: 2 }
1540"#;
1541 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1542 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1543 }
1544
1545 #[test]
1546 fn rejects_old_top_level_source_sink_with_hint() {
1547 let yaml = r#"
1549version: 1
1550source: { type: rest, config: {} }
1551sink: { type: jsonl, config: { path: ./o.jsonl } }
1552"#;
1553 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1554 let msg = err.to_string();
1555 assert!(
1556 msg.contains("pipeline"),
1557 "expected a hint about wrapping in `pipeline:`, got: {msg}"
1558 );
1559 }
1560
1561 #[test]
1562 fn rejects_unknown_extension() {
1563 let text = "version: 1\n";
1564 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1565 assert!(matches!(err, CliError::UnknownExtension { .. }));
1566 }
1567
1568 #[test]
1569 fn rejects_future_version() {
1570 let yaml = r#"
1571version: 99
1572pipeline:
1573 source: { type: rest, config: {} }
1574 sink: { type: jsonl, config: { path: ./x } }
1575"#;
1576 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1577 match err {
1578 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1579 other => panic!("expected ParseConfig, got {other:?}"),
1580 }
1581 }
1582
1583 #[test]
1584 fn transforms_and_state_round_trip() {
1585 let yaml = r#"
1586version: 1
1587pipeline:
1588 source:
1589 type: rest
1590 config: {}
1591 transforms:
1592 - type: snake_case
1593 - type: flatten
1594 config: { separator: "__" }
1595 sink:
1596 type: jsonl
1597 config: { path: "./out.jsonl" }
1598 state:
1599 type: file
1600 config: { path: "./.faucet-state" }
1601"#;
1602 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1603 assert_eq!(cfg.pipeline.transforms.len(), 2);
1604 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1605 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1606 assert_eq!(
1607 cfg.pipeline.transforms[1].config,
1608 json!({"separator": "__"})
1609 );
1610 let state = cfg.pipeline.state.unwrap();
1611 assert_eq!(state.kind, "file");
1612 }
1613
1614 #[test]
1615 fn from_path_interpolates_env_var() {
1616 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1617 let dir = tempfile::tempdir().unwrap();
1618 let path = dir.path().join("pipeline.yaml");
1619 std::fs::write(
1620 &path,
1621 r#"
1622version: 1
1623pipeline:
1624 source:
1625 type: rest
1626 config:
1627 base_url: ${env:FAUCET_CFG_URL}
1628 sink:
1629 type: jsonl
1630 config:
1631 path: ./out.jsonl
1632"#,
1633 )
1634 .unwrap();
1635 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1636 assert_eq!(
1637 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1638 "https://x.example"
1639 );
1640 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1641 }
1642
1643 #[test]
1644 fn observability_block_parses() {
1645 let y = r#"
1646version: 1
1647name: x
1648observability:
1649 prometheus:
1650 listen: "127.0.0.1:9464"
1651 buckets: [0.01, 0.1, 1.0]
1652 tracing:
1653 level: "info"
1654pipeline:
1655 source:
1656 type: rest
1657 config:
1658 base_url: "https://example.com"
1659 path: "/data"
1660 sink:
1661 type: jsonl
1662 config:
1663 path: "/tmp/faucet-test.jsonl"
1664"#;
1665 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1666 let obs = cfg.observability.expect("observability block parsed");
1667 let p = obs.prometheus.expect("prometheus parsed");
1668 assert_eq!(p.listen, "127.0.0.1:9464");
1669 assert_eq!(p.buckets.unwrap().len(), 3);
1670 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1671 }
1672
1673 #[test]
1674 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1675 let dir = tempfile::tempdir().unwrap();
1678 let path = dir.path().join("pipeline.yaml");
1679 std::fs::write(
1680 &path,
1681 r#"
1682version: 1
1683pipeline:
1684 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1685 sink: { type: jsonl, config: { path: ./o.jsonl } }
1686"#,
1687 )
1688 .unwrap();
1689 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1690 assert_eq!(
1691 cfg.pipeline.source.as_ref().unwrap().config["path"],
1692 "/v1/users/${users.id}/posts"
1693 );
1694 }
1695
1696 #[cfg(feature = "schedule")]
1697 #[test]
1698 fn parses_schedule_block() {
1699 let yaml = r#"
1700version: 1
1701schedule:
1702 cron: "0 2 * * *"
1703 timezone: "America/Los_Angeles"
1704 overlap_policy: skip
1705 max_consecutive_failures: 5
1706pipeline:
1707 source: { type: rest, config: {} }
1708 sink: { type: jsonl, config: { path: ./o.jsonl } }
1709"#;
1710 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1711 let s = cfg.schedule.expect("schedule parsed");
1712 assert_eq!(s.cron, "0 2 * * *");
1713 assert_eq!(s.timezone, "America/Los_Angeles");
1714 assert_eq!(s.max_consecutive_failures, Some(5));
1715 }
1716
1717 #[test]
1718 fn execution_spec_parses_adaptive_block() {
1719 let yaml = r#"
1720version: 1
1721pipeline:
1722 source: { type: rest, config: { base_url: https://api.example.com } }
1723 sink: { type: jsonl, config: { path: ./out.jsonl } }
1724execution:
1725 adaptive_batch_size:
1726 enabled: true
1727 min: 200
1728 max: 4000
1729 target_latency_ms: 800
1730"#;
1731 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1732 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1733 assert!(ab.enabled);
1734 assert_eq!(ab.min, 200);
1735 assert_eq!(ab.target_latency_ms, Some(800));
1736 ab.validate().unwrap();
1737 }
1738
1739 #[cfg(feature = "quality")]
1740 #[test]
1741 fn parses_quality_block() {
1742 let yaml = r#"
1743version: 1
1744pipeline:
1745 source: { type: rest, config: { url: "https://x" } }
1746 quality:
1747 record:
1748 - { type: not_null, field: id, on_failure: abort }
1749 sink: { type: stdout, config: {} }
1750"#;
1751 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1752 let q = cfg.pipeline.quality.expect("quality parsed");
1753 assert_eq!(q.record.len(), 1);
1754 }
1755
1756 #[cfg(feature = "contract")]
1757 #[test]
1758 fn parses_contract_block() {
1759 let yaml = r#"
1760version: 1
1761pipeline:
1762 source: { type: rest, config: { url: "https://x" } }
1763 contract:
1764 version: "1.0.0"
1765 on_breach: warn
1766 fields:
1767 - { name: id, type: integer }
1768 - { name: status, type: string, enum: [open, closed] }
1769 sink: { type: stdout, config: {} }
1770"#;
1771 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1772 let c = cfg.pipeline.contract.expect("contract parsed");
1773 assert_eq!(c.version, "1.0.0");
1774 assert_eq!(c.on_breach, faucet_core::OnBreach::Warn);
1775 assert_eq!(c.fields.len(), 2);
1776 }
1777
1778 #[test]
1779 fn parses_dlq_block_with_defaults() {
1780 let yaml = r#"
1781version: 1
1782pipeline:
1783 source: { type: rest, config: {} }
1784 sink: { type: jsonl, config: { path: ./o.jsonl } }
1785 dlq:
1786 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1787"#;
1788 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1789 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1790 assert_eq!(dlq.sink.kind, "jsonl");
1791 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1792 assert!(dlq.max_failures_per_page.is_none());
1793 assert!(dlq.max_failures_total.is_none());
1794 assert!(dlq.include_original_payload);
1795 }
1796
1797 #[test]
1798 fn parses_dlq_block_with_dlq_all_and_budgets() {
1799 let yaml = r#"
1800version: 1
1801pipeline:
1802 source: { type: rest, config: {} }
1803 sink: { type: jsonl, config: { path: ./o.jsonl } }
1804 dlq:
1805 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1806 on_batch_error: dlq_all
1807 max_failures_per_page: 100
1808 max_failures_total: 10000
1809"#;
1810 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1811 let dlq = cfg.pipeline.dlq.unwrap();
1812 assert_eq!(dlq.sink.kind, "kafka");
1813 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1814 assert_eq!(dlq.max_failures_per_page, Some(100));
1815 assert_eq!(dlq.max_failures_total, Some(10000));
1816 }
1817
1818 #[test]
1819 fn matrix_row_dlq_null_disables_inherited_dlq() {
1820 let yaml = r#"
1821version: 1
1822pipeline:
1823 source: { type: rest, config: {} }
1824 sink: { type: jsonl, config: { path: ./o.jsonl } }
1825 dlq:
1826 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1827matrix:
1828 - id: a
1829 - id: b
1830 dlq: null
1831"#;
1832 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1833 assert!(cfg.matrix[0].dlq.is_none());
1834 assert_eq!(cfg.matrix[1].dlq, Some(None));
1835 }
1836
1837 #[test]
1838 fn matrix_row_dlq_object_replaces_inherited_dlq() {
1839 let yaml = r#"
1840version: 1
1841pipeline:
1842 source: { type: rest, config: {} }
1843 sink: { type: jsonl, config: { path: ./o.jsonl } }
1844 dlq:
1845 sink: { type: jsonl, config: { path: ./base.jsonl } }
1846matrix:
1847 - id: a
1848 dlq:
1849 sink: { type: jsonl, config: { path: ./a.jsonl } }
1850 on_batch_error: dlq_all
1851"#;
1852 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1853 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1854 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1855 let sink_path = row_dlq.sink.config.get("path").unwrap();
1856 assert_eq!(sink_path, "./a.jsonl");
1857 }
1858
1859 #[test]
1860 fn parses_named_sources_and_sinks() {
1861 let yaml = r#"
1862version: 1
1863pipeline:
1864 sources:
1865 users_api:
1866 type: rest
1867 config: { base_url: https://api.example.com }
1868 posts_api:
1869 type: rest
1870 config: { base_url: https://api.example.com }
1871 sinks:
1872 warehouse:
1873 type: postgres
1874 config: { connection_url: "postgres://x" }
1875"#;
1876 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1877 assert!(cfg.pipeline.source.is_none());
1878 assert!(cfg.pipeline.sink.is_none());
1879 assert_eq!(cfg.pipeline.sources.len(), 2);
1880 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1881 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1882 }
1883
1884 #[test]
1885 fn legacy_singular_source_still_parses() {
1886 let yaml = r#"
1887version: 1
1888pipeline:
1889 source: { type: rest, config: {} }
1890 sink: { type: jsonl, config: { path: ./o.jsonl } }
1891"#;
1892 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1893 assert!(cfg.pipeline.source.is_some());
1894 assert!(cfg.pipeline.sink.is_some());
1895 assert!(cfg.pipeline.sources.is_empty());
1896 assert!(cfg.pipeline.sinks.is_empty());
1897 }
1898
1899 #[test]
1900 fn parses_matrix_row_with_ref_field() {
1901 let yaml = r#"
1902version: 1
1903pipeline:
1904 source: { type: rest, config: {} }
1905 sink: { type: jsonl, config: { path: ./o.jsonl } }
1906matrix:
1907 - id: load_users
1908 source:
1909 ref: users_api
1910 config: { path: /v1/users }
1911"#;
1912 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1913 let src = cfg.matrix[0].source.as_ref().unwrap();
1914 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1915 assert_eq!(src.kind, None);
1916 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1917 }
1918
1919 #[test]
1920 fn parses_top_level_vars_block() {
1921 let yaml = r#"
1922version: 1
1923vars:
1924 api_base: https://api.example.com
1925 api_token_env: API_TOKEN
1926pipeline:
1927 source: { type: rest, config: {} }
1928 sink: { type: jsonl, config: { path: ./o.jsonl } }
1929"#;
1930 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1931 let vars = cfg.vars.as_ref().unwrap();
1932 assert_eq!(vars["api_base"], "https://api.example.com");
1933 assert_eq!(vars["api_token_env"], "API_TOKEN");
1934 }
1935
1936 #[test]
1937 fn vars_block_is_optional() {
1938 let yaml = r#"
1939version: 1
1940pipeline:
1941 source: { type: rest, config: {} }
1942 sink: { type: jsonl, config: { path: ./o.jsonl } }
1943"#;
1944 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1945 assert!(cfg.vars.is_none());
1946 }
1947
1948 #[test]
1949 fn from_path_resolves_vars_at_load() {
1950 let dir = tempfile::tempdir().unwrap();
1951 let path = dir.path().join("pipeline.yaml");
1952 std::fs::write(
1953 &path,
1954 r#"
1955version: 1
1956vars:
1957 base: https://api.example.com
1958pipeline:
1959 source: { type: rest, config: { url: "${vars.base}/v1" } }
1960 sink: { type: jsonl, config: { path: ./o.jsonl } }
1961"#,
1962 )
1963 .unwrap();
1964 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1965 assert_eq!(
1966 cfg.pipeline.source.as_ref().unwrap().config["url"],
1967 "https://api.example.com/v1"
1968 );
1969 }
1970
1971 #[test]
1972 fn sync_from_path_errors_on_secret_directive() {
1973 let dir = tempfile::tempdir().unwrap();
1974 let path = dir.path().join("p.yaml");
1975 std::fs::write(
1976 &path,
1977 r#"
1978version: 1
1979pipeline:
1980 source: { type: rest, config: { url: "${vault:secret/x}" } }
1981 sink: { type: jsonl, config: { path: ./o.jsonl } }
1982"#,
1983 )
1984 .unwrap();
1985 match PipelineConfig::from_path(&path, None).unwrap_err() {
1986 CliError::SecretsRequireAsyncLoad => {}
1987 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1988 }
1989 }
1990
1991 #[test]
1992 fn from_value_accepts_v1_and_resolves_refs() {
1993 let v = serde_json::json!({
1994 "version": 1,
1995 "vars": { "out": "resolved.jsonl" },
1996 "pipeline": {
1997 "source": { "type": "csv", "config": { "path": "x.csv" } },
1998 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1999 }
2000 });
2001 let cfg = PipelineConfig::from_value(v).unwrap();
2002 assert_eq!(cfg.version, 1);
2003 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
2005 }
2006
2007 #[test]
2008 fn from_value_rejects_non_v1() {
2009 let v = serde_json::json!({ "version": 99, "pipeline": {} });
2011 let err = PipelineConfig::from_value(v).unwrap_err();
2012 match err {
2013 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
2014 other => panic!("expected ParseConfig, got {other:?}"),
2015 }
2016 }
2017
2018 #[tokio::test]
2019 async fn async_from_path_loads_without_secrets() {
2020 let dir = tempfile::tempdir().unwrap();
2021 let path = dir.path().join("p.yaml");
2022 std::fs::write(
2023 &path,
2024 r#"
2025version: 1
2026pipeline:
2027 source: { type: rest, config: { base_url: https://x } }
2028 sink: { type: jsonl, config: { path: ./o.jsonl } }
2029"#,
2030 )
2031 .unwrap();
2032 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
2033 assert_eq!(cfg.version, 1);
2034 }
2035
2036 #[cfg(feature = "lineage")]
2037 #[test]
2038 fn parses_lineage_block() {
2039 let yaml = r#"
2040version: 1
2041lineage:
2042 namespace: prod
2043 transport: { type: file, config: { path: /tmp/ol.jsonl } }
2044pipeline:
2045 source: { type: rest, config: {} }
2046 sink: { type: jsonl, config: { path: ./o.jsonl } }
2047"#;
2048 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2049 let l = cfg.lineage.expect("lineage parsed");
2050 assert_eq!(l.namespace, "prod");
2051 }
2052
2053 #[test]
2054 fn from_path_resolves_extends_and_profile() {
2055 let dir = tempfile::tempdir().unwrap();
2056 std::fs::write(
2057 dir.path().join("base.yaml"),
2058 "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",
2059 )
2060 .unwrap();
2061 let app = dir.path().join("app.yaml");
2062 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
2063
2064 let cfg = PipelineConfig::from_path(&app, None).unwrap();
2066 assert_eq!(
2067 cfg.pipeline.sink.as_ref().unwrap().config["path"],
2068 "base.jsonl"
2069 );
2070
2071 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
2073 assert_eq!(
2074 cfg.pipeline.sink.as_ref().unwrap().config["path"],
2075 "prod.jsonl"
2076 );
2077 }
2078
2079 #[test]
2080 fn from_value_rejects_extends_with_composition_hint() {
2081 let v = serde_json::json!({
2083 "version": 1,
2084 "extends": "base.yaml",
2085 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
2086 });
2087 let err = PipelineConfig::from_value(v).unwrap_err();
2088 let msg = err.to_string();
2089 assert!(
2090 msg.contains("composition"),
2091 "expected composition hint, got: {msg}"
2092 );
2093 }
2094
2095 #[test]
2096 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
2097 let yaml = r#"
2099version: 1
2100pipeline:
2101 source: { type: rest, config: {} }
2102 sink: { type: jsonl, config: { path: ./o.jsonl } }
2103"#;
2104 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2105 assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
2106
2107 let yaml2 = r#"
2109version: 1
2110delivery: exactly_once
2111pipeline:
2112 source: { type: rest, config: {} }
2113 sink: { type: jsonl, config: { path: ./o.jsonl } }
2114"#;
2115 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
2116 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
2117
2118 let yaml3 = r#"
2120version: 1
2121delivery: at_least_once
2122pipeline:
2123 source: { type: rest, config: {} }
2124 sink: { type: jsonl, config: { path: ./o.jsonl } }
2125matrix:
2126 - id: a
2127 - id: b
2128 delivery: exactly_once
2129"#;
2130 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
2131 assert_eq!(cfg3.matrix[0].delivery, None);
2132 assert_eq!(
2133 cfg3.matrix[1].delivery,
2134 Some(faucet_core::DeliveryMode::ExactlyOnce)
2135 );
2136 }
2137
2138 #[test]
2139 fn resilience_spec_parses_and_builds_policy() {
2140 let yaml = r#"
2141version: 1
2142pipeline:
2143 source: { type: rest, config: { base_url: "https://x" } }
2144 sink: { type: stdout, config: {} }
2145resilience:
2146 retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
2147 retry_on: [http_5xx, timeout]
2148 circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
2149 poison: { max_row_attempts: 2, action: dlq }
2150"#;
2151 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2152 let spec = cfg.resilience.unwrap();
2153 let policy = spec.to_policy().unwrap();
2154 assert_eq!(policy.retry.max_attempts, 4);
2155 assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
2156 assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
2157 }
2158
2159 #[test]
2160 fn resilience_rejects_zero_max_attempts() {
2161 let yaml = r#"
2162version: 1
2163pipeline:
2164 source: { type: rest, config: { base_url: "https://x" } }
2165 sink: { type: stdout, config: {} }
2166resilience: { retry: { max_attempts: 0 } }
2167"#;
2168 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2169 let err = cfg.resilience.unwrap().to_policy().unwrap_err();
2170 assert!(err.to_string().contains("max_attempts"));
2171 }
2172
2173 #[test]
2174 fn observability_parses_otel_block() {
2175 let yaml = r#"
2176version: 1
2177pipeline:
2178 source: { type: rest, config: { base_url: "http://x" } }
2179 sink: { type: stdout, config: {} }
2180observability:
2181 otel:
2182 endpoint: http://collector:4317
2183 protocol: grpc
2184 export: [traces, metrics]
2185"#;
2186 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2187 let otel = cfg.observability.unwrap().otel.unwrap();
2188 assert_eq!(otel.endpoint, "http://collector:4317");
2189 }
2190
2191 #[test]
2192 fn otel_validation_rejects_bad_ratio() {
2193 let yaml = r#"
2194version: 1
2195pipeline:
2196 source: { type: rest, config: { base_url: "http://x" } }
2197 sink: { type: stdout, config: {} }
2198observability:
2199 otel:
2200 sample_ratio: 9.0
2201"#;
2202 let err = parse_with_extension(yaml, "yaml").unwrap_err();
2203 assert!(format!("{err}").contains("sample_ratio"));
2204 }
2205}