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 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub partition: Option<crate::partition::PartitionSpec>,
143
144 #[cfg(feature = "schedule")]
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
149
150 #[cfg(feature = "lineage")]
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub lineage: Option<faucet_lineage::LineageConfig>,
154
155 #[cfg(feature = "catalog")]
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub catalog: Option<crate::catalog::CatalogSpec>,
163
164 #[cfg(feature = "notify")]
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub notifications: Vec<crate::notify::NotificationSpec>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
178#[serde(deny_unknown_fields)]
179pub struct PipelineSpec {
180 #[serde(default)]
183 pub source: Option<ConnectorSpec>,
184
185 #[serde(default)]
187 pub sink: Option<ConnectorSpec>,
188
189 #[serde(default)]
191 pub sources: HashMap<String, ConnectorSpec>,
192
193 #[serde(default)]
195 pub sinks: HashMap<String, ConnectorSpec>,
196
197 #[serde(default)]
198 pub transforms: Vec<TransformSpec>,
199 #[serde(default)]
200 pub state: Option<StateStoreSpec>,
201 #[serde(default)]
202 pub dlq: Option<DlqSpec>,
203
204 #[cfg(feature = "quality")]
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub quality: Option<faucet_core::QualitySpec>,
208
209 #[cfg(feature = "contract")]
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub contract: Option<faucet_core::ContractSpec>,
215
216 #[cfg(feature = "masking")]
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub masking: Option<faucet_core::MaskingSpec>,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub schema: Option<faucet_core::SchemaDriftSpec>,
229
230 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
235 pub nodes: HashMap<String, NodeSpec>,
236
237 #[serde(default, skip_serializing_if = "Vec::is_empty")]
241 pub edges: Vec<EdgeSpec>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253#[serde(tag = "kind", rename_all = "lowercase")]
254pub enum NodeSpec {
255 Source {
257 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
259 template: Option<String>,
260 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
262 kind: Option<String>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 config: Option<Value>,
266 },
267 Sink {
269 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
271 template: Option<String>,
272 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
274 kind: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 config: Option<Value>,
278 },
279 Transform {
281 #[serde(default)]
283 transforms: Vec<TransformSpec>,
284 },
285 Tee {
287 #[serde(default = "default_channel_capacity")]
289 channel_capacity: usize,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 fanout: Option<usize>,
293 },
294 Merge,
296 Join(JoinSpec),
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
302#[serde(deny_unknown_fields)]
303pub struct JoinSpec {
304 #[serde(default)]
306 pub mode: faucet_core::JoinMode,
307 pub build: JoinSide,
309 pub probe: JoinSide,
311 #[serde(default)]
313 pub project: Vec<faucet_core::Projection>,
314 #[serde(default)]
316 pub on_missing: Value,
317 #[serde(default)]
319 pub on_duplicate: faucet_core::OnDuplicate,
320 #[serde(default)]
322 pub on_collision: faucet_core::OnCollision,
323 #[serde(default)]
325 pub key_normalize: faucet_core::KeyNormalize,
326 #[serde(default = "default_max_build_records")]
328 pub max_build_records: usize,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333#[serde(deny_unknown_fields)]
334pub struct JoinSide {
335 pub edge: String,
337 pub key: String,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
343#[serde(deny_unknown_fields)]
344pub struct EdgeSpec {
345 pub from: String,
347 pub to: String,
349 #[serde(rename = "as", default, skip_serializing_if = "Option::is_none")]
351 pub label: Option<String>,
352}
353
354fn default_channel_capacity() -> usize {
355 faucet_core::topology::DEFAULT_CHANNEL_CAPACITY
356}
357
358fn default_max_build_records() -> usize {
359 faucet_core::join::DEFAULT_MAX_BUILD_RECORDS
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
367#[serde(deny_unknown_fields)]
368pub struct ConnectorSpec {
369 #[serde(rename = "type")]
372 pub kind: String,
373
374 #[serde(default = "empty_object")]
377 pub config: Value,
378
379 #[serde(default)]
383 pub transforms: Option<Vec<TransformSpec>>,
384
385 #[serde(default = "default_true")]
389 pub inherit_transforms: bool,
390
391 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub status: Option<SourceStatus>,
397
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub tags: Vec<String>,
404
405 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub complete_for: Option<CompletenessClaim>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
418#[serde(deny_unknown_fields)]
419pub struct CompletenessClaim {
420 pub scope: std::collections::BTreeMap<String, Value>,
426
427 #[serde(default)]
431 pub on_missing: OnMissing,
432}
433
434#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
437#[serde(rename_all = "snake_case")]
438pub enum OnMissing {
439 #[default]
441 Ignore,
442 Delete,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
454#[serde(deny_unknown_fields)]
455pub struct PartialConnector {
456 #[serde(default)]
459 pub r#ref: Option<String>,
460 #[serde(rename = "type", default)]
462 pub kind: Option<String>,
463 #[serde(default)]
465 pub config: Option<Value>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub status: Option<SourceStatus>,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
475#[serde(deny_unknown_fields)]
476pub struct TransformSpec {
477 #[serde(rename = "type")]
482 pub kind: String,
483
484 #[serde(default = "empty_object")]
486 pub config: Value,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
491#[serde(deny_unknown_fields)]
492pub struct StateStoreSpec {
493 #[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 MatrixRow {
506 #[serde(default)]
509 pub id: Option<String>,
510
511 #[serde(default)]
513 pub parent: Option<String>,
514
515 #[serde(default)]
520 pub depends_on: Vec<String>,
521
522 #[serde(default = "default_parent_key")]
525 pub parent_key: String,
526
527 #[serde(default)]
529 pub source: Option<PartialConnector>,
530
531 #[serde(default)]
533 pub sink: Option<PartialConnector>,
534
535 #[serde(default)]
540 pub transforms: Option<Vec<TransformSpec>>,
541
542 #[serde(default = "default_true")]
545 pub inherit_transforms: bool,
546
547 #[serde(default)]
549 pub state: Option<StateStoreSpec>,
550
551 #[serde(default, deserialize_with = "deserialize_dlq_override")]
556 pub dlq: Option<Option<DlqSpec>>,
557
558 #[serde(default)]
560 pub delivery: Option<faucet_core::DeliveryMode>,
561
562 #[serde(default, skip_serializing_if = "Vec::is_empty")]
569 pub tags: Vec<String>,
570
571 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub partition: Option<crate::partition::PartitionSpec>,
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
583#[serde(rename_all = "snake_case")]
584pub enum SourceStatus {
585 Mandatory,
587 #[default]
589 Active,
590 Available,
592 Draft,
594 Archived,
596}
597
598impl SourceStatus {
599 pub const ALL: [SourceStatus; 5] = [
601 SourceStatus::Mandatory,
602 SourceStatus::Active,
603 SourceStatus::Available,
604 SourceStatus::Draft,
605 SourceStatus::Archived,
606 ];
607
608 pub fn as_str(self) -> &'static str {
610 match self {
611 SourceStatus::Mandatory => "mandatory",
612 SourceStatus::Active => "active",
613 SourceStatus::Available => "available",
614 SourceStatus::Draft => "draft",
615 SourceStatus::Archived => "archived",
616 }
617 }
618
619 pub fn parse(s: &str) -> Option<Self> {
622 SourceStatus::ALL.into_iter().find(|v| v.as_str() == s)
623 }
624
625 pub fn default_eligible(self) -> bool {
627 matches!(self, SourceStatus::Mandatory | SourceStatus::Active)
628 }
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
637#[serde(rename_all = "snake_case")]
638pub enum IncludeParents {
639 #[default]
642 Off,
643 Eligible,
646 All,
649}
650
651impl IncludeParents {
652 pub fn as_str(self) -> &'static str {
654 match self {
655 IncludeParents::Off => "off",
656 IncludeParents::Eligible => "eligible",
657 IncludeParents::All => "all",
658 }
659 }
660
661 pub fn parse(s: &str) -> Option<Self> {
663 match s {
664 "off" => Some(IncludeParents::Off),
665 "eligible" => Some(IncludeParents::Eligible),
666 "all" => Some(IncludeParents::All),
667 _ => None,
668 }
669 }
670}
671
672#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
675#[serde(deny_unknown_fields)]
676pub struct SelectionSpec {
677 #[serde(default)]
679 pub include_parents: IncludeParents,
680}
681
682#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
684#[serde(deny_unknown_fields)]
685pub struct ExecutionSpec {
686 #[serde(default)]
690 pub max_concurrent: Option<usize>,
691
692 #[serde(default)]
694 pub on_error: OnError,
695
696 #[serde(default)]
698 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
699}
700
701#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
703#[serde(deny_unknown_fields)]
704pub struct ShardingSpec {
705 pub count: usize,
710}
711
712#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
714#[serde(rename_all = "lowercase")]
715pub enum OnError {
716 #[default]
718 Continue,
719 Stop,
721}
722
723#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
725pub struct ObservabilitySpec {
726 #[serde(default)]
728 pub prometheus: Option<PrometheusSpec>,
729
730 #[serde(default)]
732 pub tracing: Option<TracingSpec>,
733
734 #[serde(default)]
736 pub otel: Option<OtelSpec>,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
741pub struct PrometheusSpec {
742 pub listen: String,
744
745 #[serde(default)]
748 pub buckets: Option<Vec<f64>>,
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
753pub struct TracingSpec {
754 #[serde(default)]
757 pub level: Option<String>,
758}
759
760#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
762pub struct OtelSpec {
763 #[serde(default)]
766 pub endpoint: String,
767 #[serde(default)]
769 pub protocol: faucet_core::OtelProtocol,
770 #[serde(default)]
772 pub headers: std::collections::HashMap<String, String>,
773 #[serde(default = "default_otel_ratio")]
775 pub sample_ratio: f64,
776 #[serde(default = "default_otel_export")]
778 pub export: Vec<faucet_core::OtelSignal>,
779 #[serde(default = "default_otel_service")]
781 pub service_name: String,
782 #[serde(default = "default_otel_timeout")]
784 pub timeout_secs: u64,
785 #[serde(default = "default_otel_interval")]
787 pub metric_interval_secs: u64,
788}
789
790fn default_otel_ratio() -> f64 {
791 1.0
792}
793fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
794 vec![
795 faucet_core::OtelSignal::Traces,
796 faucet_core::OtelSignal::Metrics,
797 ]
798}
799fn default_otel_service() -> String {
800 "faucet".to_string()
801}
802fn default_otel_timeout() -> u64 {
803 10
804}
805fn default_otel_interval() -> u64 {
806 60
807}
808
809impl OtelSpec {
810 pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
812 let cfg = faucet_core::OtelConfig {
813 endpoint: self.endpoint.clone(),
814 protocol: self.protocol,
815 headers: self.headers.clone(),
816 sample_ratio: self.sample_ratio,
817 export: self.export.clone(),
818 service_name: self.service_name.clone(),
819 timeout_secs: self.timeout_secs,
820 metric_interval_secs: self.metric_interval_secs,
821 };
822 cfg.validate()?;
823 Ok(cfg)
824 }
825}
826
827#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
831#[serde(rename_all = "snake_case")]
832pub enum OnBatchErrorSpec {
833 #[default]
834 Propagate,
835 DlqAll,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
840#[serde(deny_unknown_fields)]
841pub struct DlqSpec {
842 pub sink: ConnectorSpec,
843 #[serde(default)]
844 pub on_batch_error: OnBatchErrorSpec,
845 #[serde(default)]
846 pub max_failures_per_page: Option<usize>,
847 #[serde(default)]
848 pub max_failures_total: Option<usize>,
849 #[serde(default = "default_true")]
850 pub include_original_payload: bool,
851}
852
853#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
855#[serde(deny_unknown_fields)]
856pub struct ResilienceSpec {
857 #[serde(default)]
860 pub retry: RetrySpec,
861 #[serde(default)]
863 pub retry_on: Option<Vec<faucet_core::RetryClass>>,
864 #[serde(default)]
866 pub circuit_breaker: Option<CircuitBreakerSpec>,
867 #[serde(default)]
869 pub poison: Option<PoisonSpec>,
870}
871
872#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
874#[serde(deny_unknown_fields)]
875pub struct RetrySpec {
876 #[serde(default = "default_max_attempts")]
878 pub max_attempts: u32,
879 #[serde(default)]
881 pub backoff: BackoffSpec,
882 #[serde(default = "default_base_ms")]
884 pub base_ms: u64,
885 #[serde(default = "default_max_ms")]
887 pub max_ms: u64,
888 #[serde(default = "default_true")]
890 pub jitter: bool,
891}
892
893impl Default for RetrySpec {
894 fn default() -> Self {
895 Self {
896 max_attempts: default_max_attempts(),
897 backoff: BackoffSpec::default(),
898 base_ms: default_base_ms(),
899 max_ms: default_max_ms(),
900 jitter: true,
901 }
902 }
903}
904
905#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
907#[serde(rename_all = "snake_case")]
908pub enum BackoffSpec {
909 None,
911 Fixed,
913 #[default]
915 Exponential,
916}
917
918#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
920#[serde(deny_unknown_fields)]
921pub struct CircuitBreakerSpec {
922 pub consecutive_failures: u32,
924 pub cooldown_secs: u64,
926}
927
928#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
930#[serde(deny_unknown_fields)]
931pub struct PoisonSpec {
932 pub max_row_attempts: u32,
934 #[serde(default)]
936 pub action: PoisonActionSpec,
937}
938
939#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
942#[serde(rename_all = "snake_case")]
943pub enum PoisonActionSpec {
944 #[default]
946 Dlq,
947 Drop,
949 Fail,
951}
952
953fn default_max_attempts() -> u32 {
954 5
955}
956fn default_base_ms() -> u64 {
957 200
958}
959fn default_max_ms() -> u64 {
960 30_000
961}
962
963impl ResilienceSpec {
964 pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
966 use crate::error::CliError;
967 if self.retry.max_attempts < 1 {
968 return Err(CliError::Config(
969 "resilience.retry.max_attempts must be >= 1".into(),
970 ));
971 }
972 if self.retry.base_ms > self.retry.max_ms {
973 return Err(CliError::Config(
974 "resilience.retry.base_ms must be <= max_ms".into(),
975 ));
976 }
977 let retry_on = match &self.retry_on {
978 Some(v) if v.is_empty() => {
979 return Err(CliError::Config(
980 "resilience.retry_on must not be empty".into(),
981 ));
982 }
983 Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
984 None => faucet_core::RetryClassSet::default(),
985 };
986 let backoff = match self.retry.backoff {
987 BackoffSpec::None => faucet_core::BackoffKind::None,
988 BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
989 BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
990 };
991 let circuit_breaker = match self.circuit_breaker {
992 Some(cb) if cb.consecutive_failures < 1 => {
993 return Err(CliError::Config(
994 "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
995 ));
996 }
997 Some(cb) => Some(faucet_core::CircuitBreakerConfig {
998 consecutive_failures: cb.consecutive_failures,
999 cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
1000 }),
1001 None => None,
1002 };
1003 let poison = match self.poison {
1004 Some(p) if p.max_row_attempts < 1 => {
1005 return Err(CliError::Config(
1006 "resilience.poison.max_row_attempts must be >= 1".into(),
1007 ));
1008 }
1009 Some(p) => Some(faucet_core::PoisonPolicy {
1010 max_row_attempts: p.max_row_attempts,
1011 action: match p.action {
1012 PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
1013 PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
1014 PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
1015 },
1016 }),
1017 None => None,
1018 };
1019 Ok(faucet_core::ResiliencePolicy {
1020 retry: faucet_core::RetryPolicy {
1021 max_attempts: self.retry.max_attempts,
1022 backoff,
1023 base: std::time::Duration::from_millis(self.retry.base_ms),
1024 max: std::time::Duration::from_millis(self.retry.max_ms),
1025 jitter: self.retry.jitter,
1026 retry_on,
1027 },
1028 circuit_breaker,
1029 poison,
1030 })
1031 }
1032}
1033
1034fn default_true() -> bool {
1035 true
1036}
1037
1038fn default_version() -> u32 {
1039 1
1040}
1041fn default_parent_key() -> String {
1042 "id".to_owned()
1043}
1044fn empty_object() -> Value {
1045 Value::Object(Default::default())
1046}
1047
1048fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
1049where
1050 D: serde::Deserializer<'de>,
1051{
1052 Option::<DlqSpec>::deserialize(deserializer).map(Some)
1053}
1054
1055#[derive(Debug, Clone)]
1075pub struct RunInputs {
1076 pub params: crate::params::SuppliedParams,
1079 pub env: crate::interpolate::EnvOverlay,
1082 pub mode: crate::params::BindMode,
1084}
1085
1086impl Default for RunInputs {
1087 fn default() -> Self {
1088 Self {
1089 params: Default::default(),
1090 env: Default::default(),
1091 mode: crate::params::BindMode::Strict,
1092 }
1093 }
1094}
1095
1096impl RunInputs {
1097 pub fn placeholders() -> Self {
1100 Self {
1101 mode: crate::params::BindMode::Placeholder,
1102 ..Self::default()
1103 }
1104 }
1105
1106 pub fn with_params(params: crate::params::SuppliedParams) -> Self {
1108 Self {
1109 params,
1110 ..Self::default()
1111 }
1112 }
1113}
1114
1115fn resolve_document(text: &str, path: &Path, inputs: &RunInputs) -> CliResult<String> {
1116 use crate::interpolate::interpolate_value_with_env;
1117 let ext = path
1118 .extension()
1119 .and_then(|e| e.to_str())
1120 .map(str::to_ascii_lowercase);
1121 let resolve = |value: &mut serde_json::Value| -> CliResult<()> {
1126 interpolate_value_with_env(value, &inputs.env)?;
1127 crate::params::bind_document(value, &inputs.params, inputs.mode)?;
1128 Ok(())
1129 };
1130 match ext.as_deref() {
1131 Some("yaml" | "yml") => {
1132 let mut value: serde_json::Value =
1133 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1134 path: path.to_path_buf(),
1135 message: friendly_parse_error(&e.to_string()),
1136 })?;
1137 resolve(&mut value)?;
1138 serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
1139 path: path.to_path_buf(),
1140 message: e.to_string(),
1141 })
1142 }
1143 Some("json") => {
1144 let mut value: serde_json::Value =
1145 serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1146 path: path.to_path_buf(),
1147 message: friendly_parse_error(&e.to_string()),
1148 })?;
1149 resolve(&mut value)?;
1150 serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
1151 path: path.to_path_buf(),
1152 message: e.to_string(),
1153 })
1154 }
1155 _ => Err(CliError::UnknownExtension {
1156 path: path.to_path_buf(),
1157 }),
1158 }
1159}
1160
1161impl PipelineConfig {
1162 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1175 Self::from_path_with(path, profile, &RunInputs::default())
1176 }
1177
1178 pub fn from_path_with(
1181 path: impl AsRef<Path>,
1182 profile: Option<&str>,
1183 inputs: &RunInputs,
1184 ) -> CliResult<Self> {
1185 let path = path.as_ref();
1186 let composed = crate::compose::compose(path, profile)?;
1187 let interpolated = resolve_document(&composed, path, inputs)?;
1188 let cfg = Self::from_text(&interpolated, path)?;
1189 crate::secrets::ensure_no_secret_directives(&cfg)?;
1192 Ok(cfg)
1193 }
1194
1195 pub fn from_path_tolerating_secrets(
1199 path: impl AsRef<Path>,
1200 profile: Option<&str>,
1201 ) -> CliResult<Self> {
1202 Self::from_path_tolerating_secrets_with(path, profile, &RunInputs::default())
1203 }
1204
1205 pub fn from_path_tolerating_secrets_with(
1207 path: impl AsRef<Path>,
1208 profile: Option<&str>,
1209 inputs: &RunInputs,
1210 ) -> CliResult<Self> {
1211 let path = path.as_ref();
1212 let composed = crate::compose::compose(path, profile)?;
1213 let interpolated = resolve_document(&composed, path, inputs)?;
1214 Self::from_text(&interpolated, path)
1215 }
1216
1217 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1221 Self::from_path_async_with(path, profile, &RunInputs::default()).await
1222 }
1223
1224 pub async fn from_path_async_with(
1227 path: impl AsRef<Path>,
1228 profile: Option<&str>,
1229 inputs: &RunInputs,
1230 ) -> CliResult<Self> {
1231 let path = path.as_ref();
1232 let composed = crate::compose::compose(path, profile)?;
1233 let interpolated = resolve_document(&composed, path, inputs)?;
1234 let mut cfg = Self::from_text(&interpolated, path)?;
1235 crate::secrets::resolve_secrets(&mut cfg).await?;
1236 Ok(cfg)
1237 }
1238
1239 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
1242 let ext = path
1243 .extension()
1244 .and_then(|e| e.to_str())
1245 .map(str::to_ascii_lowercase);
1246 let cfg: PipelineConfig = match ext.as_deref() {
1247 Some("yaml" | "yml") => {
1248 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1249 path: path.to_path_buf(),
1250 message: friendly_parse_error(&e.to_string()),
1251 })?
1252 }
1253 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1254 path: path.to_path_buf(),
1255 message: friendly_parse_error(&e.to_string()),
1256 })?,
1257 _ => {
1258 return Err(CliError::UnknownExtension {
1259 path: path.to_path_buf(),
1260 });
1261 }
1262 };
1263 Self::finish(cfg, path)
1264 }
1265
1266 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
1274 let synthetic = Path::new("<submitted>");
1275 let cfg: PipelineConfig =
1276 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
1277 path: synthetic.to_path_buf(),
1278 message: friendly_parse_error(&e.to_string()),
1279 })?;
1280 Self::finish(cfg, synthetic)
1281 }
1282
1283 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
1285 if cfg.version != 1 {
1286 return Err(CliError::ParseConfig {
1287 path: path.to_path_buf(),
1288 message: format!(
1289 "unsupported pipeline version {}, only version 1 is recognised",
1290 cfg.version
1291 ),
1292 });
1293 }
1294 crate::interpolate::resolve_config_refs(&mut cfg)?;
1295 if let Some(obs) = cfg.observability.as_ref()
1296 && let Some(otel) = obs.otel.as_ref()
1297 {
1298 otel.to_core().map_err(CliError::Config)?;
1299 }
1300 Ok(cfg)
1301 }
1302}
1303
1304fn friendly_parse_error(raw: &str) -> String {
1307 let lower = raw.to_ascii_lowercase();
1308 if lower.contains("missing field `pipeline`") {
1309 return format!(
1310 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
1311 );
1312 }
1313 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
1314 return format!(
1315 "{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."
1316 );
1317 }
1318 raw.to_owned()
1319}
1320
1321pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
1324 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
1325 PipelineConfig::from_text(text, &synthetic)
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::*;
1331 use serde_json::json;
1332
1333 #[test]
1334 fn parses_minimal_pipeline_yaml() {
1335 let yaml = r#"
1336version: 1
1337pipeline:
1338 source:
1339 type: rest
1340 config:
1341 base_url: https://api.example.com
1342 sink:
1343 type: jsonl
1344 config:
1345 path: ./out.jsonl
1346"#;
1347 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1348 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1349 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
1350 assert!(cfg.matrix.is_empty());
1351 assert!(cfg.execution.is_none());
1352 assert!(cfg.pipeline.transforms.is_empty());
1353 assert!(cfg.pipeline.state.is_none());
1354 }
1355
1356 #[test]
1357 fn parses_replication_block() {
1358 let yaml = r#"
1359version: 1
1360pipeline:
1361 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
1362 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1363 state: { type: file, config: { path: ./st } }
1364replication:
1365 mode: snapshot_then_cdc
1366 snapshot:
1367 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
1368"#;
1369 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1370 let r = cfg.replication.expect("replication parsed");
1371 assert_eq!(r.snapshot.source.kind, "postgres");
1372 }
1373
1374 #[test]
1375 fn pipeline_spec_parses_schema_block() {
1376 let yaml = r#"
1377version: 1
1378pipeline:
1379 source:
1380 type: rest
1381 config:
1382 base_url: https://api.example.com
1383 sink:
1384 type: jsonl
1385 config:
1386 path: ./out.jsonl
1387 schema:
1388 on_drift: evolve
1389 allow_type_widening: false
1390"#;
1391 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1392 let schema = cfg.pipeline.schema.expect("schema block parsed");
1393 assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
1394 assert!(!schema.allow_type_widening);
1395 }
1396
1397 #[test]
1398 fn parses_minimal_json() {
1399 let raw = r#"{
1400 "version": 1,
1401 "pipeline": {
1402 "source": {"type": "rest", "config": {}},
1403 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
1404 }
1405 }"#;
1406 let cfg = parse_with_extension(raw, "json").unwrap();
1407 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1408 }
1409
1410 #[test]
1411 fn parses_matrix_rows_with_partial_overrides() {
1412 let yaml = r#"
1413version: 1
1414pipeline:
1415 source: { type: rest, config: { base_url: https://api.example.com } }
1416 sink: { type: jsonl, config: { path: ./out.jsonl } }
1417matrix:
1418 - id: users
1419 source: { config: { path: /v1/users } }
1420 sink: { config: { path: ./users.jsonl } }
1421 - id: posts
1422 parent: users
1423 parent_key: user_id
1424 source: { config: { path: "/v1/users/${users.id}/posts" } }
1425"#;
1426 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1427 assert_eq!(cfg.matrix.len(), 2);
1428 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
1429 assert!(cfg.matrix[0].parent.is_none());
1430 let users_src = cfg.matrix[0].source.as_ref().unwrap();
1431 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
1432
1433 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
1434 assert_eq!(cfg.matrix[1].parent_key, "user_id");
1435 }
1436
1437 #[test]
1438 fn parent_key_defaults_to_id() {
1439 let yaml = r#"
1440version: 1
1441pipeline:
1442 source: { type: rest, config: {} }
1443 sink: { type: jsonl, config: { path: ./o.jsonl } }
1444matrix:
1445 - { id: users }
1446 - { id: posts, parent: users }
1447"#;
1448 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1449 assert_eq!(cfg.matrix[1].parent_key, "id");
1450 }
1451
1452 #[test]
1453 fn parses_execution_block() {
1454 let yaml = r#"
1455version: 1
1456pipeline:
1457 source: { type: rest, config: {} }
1458 sink: { type: jsonl, config: { path: ./o.jsonl } }
1459execution:
1460 max_concurrent: 8
1461 on_error: stop
1462"#;
1463 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1464 let exec = cfg.execution.unwrap();
1465 assert_eq!(exec.max_concurrent, Some(8));
1466 assert_eq!(exec.on_error, OnError::Stop);
1467 }
1468
1469 #[test]
1470 fn on_error_defaults_to_continue() {
1471 let yaml = r#"
1472version: 1
1473pipeline:
1474 source: { type: rest, config: {} }
1475 sink: { type: jsonl, config: { path: ./o.jsonl } }
1476execution: { max_concurrent: 2 }
1477"#;
1478 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1479 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1480 }
1481
1482 #[test]
1483 fn rejects_old_top_level_source_sink_with_hint() {
1484 let yaml = r#"
1486version: 1
1487source: { type: rest, config: {} }
1488sink: { type: jsonl, config: { path: ./o.jsonl } }
1489"#;
1490 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1491 let msg = err.to_string();
1492 assert!(
1493 msg.contains("pipeline"),
1494 "expected a hint about wrapping in `pipeline:`, got: {msg}"
1495 );
1496 }
1497
1498 #[test]
1499 fn rejects_unknown_extension() {
1500 let text = "version: 1\n";
1501 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1502 assert!(matches!(err, CliError::UnknownExtension { .. }));
1503 }
1504
1505 #[test]
1506 fn rejects_future_version() {
1507 let yaml = r#"
1508version: 99
1509pipeline:
1510 source: { type: rest, config: {} }
1511 sink: { type: jsonl, config: { path: ./x } }
1512"#;
1513 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1514 match err {
1515 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1516 other => panic!("expected ParseConfig, got {other:?}"),
1517 }
1518 }
1519
1520 #[test]
1521 fn transforms_and_state_round_trip() {
1522 let yaml = r#"
1523version: 1
1524pipeline:
1525 source:
1526 type: rest
1527 config: {}
1528 transforms:
1529 - type: snake_case
1530 - type: flatten
1531 config: { separator: "__" }
1532 sink:
1533 type: jsonl
1534 config: { path: "./out.jsonl" }
1535 state:
1536 type: file
1537 config: { path: "./.faucet-state" }
1538"#;
1539 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1540 assert_eq!(cfg.pipeline.transforms.len(), 2);
1541 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1542 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1543 assert_eq!(
1544 cfg.pipeline.transforms[1].config,
1545 json!({"separator": "__"})
1546 );
1547 let state = cfg.pipeline.state.unwrap();
1548 assert_eq!(state.kind, "file");
1549 }
1550
1551 #[test]
1552 fn from_path_interpolates_env_var() {
1553 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1554 let dir = tempfile::tempdir().unwrap();
1555 let path = dir.path().join("pipeline.yaml");
1556 std::fs::write(
1557 &path,
1558 r#"
1559version: 1
1560pipeline:
1561 source:
1562 type: rest
1563 config:
1564 base_url: ${env:FAUCET_CFG_URL}
1565 sink:
1566 type: jsonl
1567 config:
1568 path: ./out.jsonl
1569"#,
1570 )
1571 .unwrap();
1572 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1573 assert_eq!(
1574 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1575 "https://x.example"
1576 );
1577 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1578 }
1579
1580 #[test]
1581 fn observability_block_parses() {
1582 let y = r#"
1583version: 1
1584name: x
1585observability:
1586 prometheus:
1587 listen: "127.0.0.1:9464"
1588 buckets: [0.01, 0.1, 1.0]
1589 tracing:
1590 level: "info"
1591pipeline:
1592 source:
1593 type: rest
1594 config:
1595 base_url: "https://example.com"
1596 path: "/data"
1597 sink:
1598 type: jsonl
1599 config:
1600 path: "/tmp/faucet-test.jsonl"
1601"#;
1602 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1603 let obs = cfg.observability.expect("observability block parsed");
1604 let p = obs.prometheus.expect("prometheus parsed");
1605 assert_eq!(p.listen, "127.0.0.1:9464");
1606 assert_eq!(p.buckets.unwrap().len(), 3);
1607 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1608 }
1609
1610 #[test]
1611 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1612 let dir = tempfile::tempdir().unwrap();
1615 let path = dir.path().join("pipeline.yaml");
1616 std::fs::write(
1617 &path,
1618 r#"
1619version: 1
1620pipeline:
1621 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1622 sink: { type: jsonl, config: { path: ./o.jsonl } }
1623"#,
1624 )
1625 .unwrap();
1626 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1627 assert_eq!(
1628 cfg.pipeline.source.as_ref().unwrap().config["path"],
1629 "/v1/users/${users.id}/posts"
1630 );
1631 }
1632
1633 #[cfg(feature = "schedule")]
1634 #[test]
1635 fn parses_schedule_block() {
1636 let yaml = r#"
1637version: 1
1638schedule:
1639 cron: "0 2 * * *"
1640 timezone: "America/Los_Angeles"
1641 overlap_policy: skip
1642 max_consecutive_failures: 5
1643pipeline:
1644 source: { type: rest, config: {} }
1645 sink: { type: jsonl, config: { path: ./o.jsonl } }
1646"#;
1647 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1648 let s = cfg.schedule.expect("schedule parsed");
1649 assert_eq!(s.cron, "0 2 * * *");
1650 assert_eq!(s.timezone, "America/Los_Angeles");
1651 assert_eq!(s.max_consecutive_failures, Some(5));
1652 }
1653
1654 #[test]
1655 fn execution_spec_parses_adaptive_block() {
1656 let yaml = r#"
1657version: 1
1658pipeline:
1659 source: { type: rest, config: { base_url: https://api.example.com } }
1660 sink: { type: jsonl, config: { path: ./out.jsonl } }
1661execution:
1662 adaptive_batch_size:
1663 enabled: true
1664 min: 200
1665 max: 4000
1666 target_latency_ms: 800
1667"#;
1668 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1669 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1670 assert!(ab.enabled);
1671 assert_eq!(ab.min, 200);
1672 assert_eq!(ab.target_latency_ms, Some(800));
1673 ab.validate().unwrap();
1674 }
1675
1676 #[cfg(feature = "quality")]
1677 #[test]
1678 fn parses_quality_block() {
1679 let yaml = r#"
1680version: 1
1681pipeline:
1682 source: { type: rest, config: { url: "https://x" } }
1683 quality:
1684 record:
1685 - { type: not_null, field: id, on_failure: abort }
1686 sink: { type: stdout, config: {} }
1687"#;
1688 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1689 let q = cfg.pipeline.quality.expect("quality parsed");
1690 assert_eq!(q.record.len(), 1);
1691 }
1692
1693 #[cfg(feature = "contract")]
1694 #[test]
1695 fn parses_contract_block() {
1696 let yaml = r#"
1697version: 1
1698pipeline:
1699 source: { type: rest, config: { url: "https://x" } }
1700 contract:
1701 version: "1.0.0"
1702 on_breach: warn
1703 fields:
1704 - { name: id, type: integer }
1705 - { name: status, type: string, enum: [open, closed] }
1706 sink: { type: stdout, config: {} }
1707"#;
1708 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1709 let c = cfg.pipeline.contract.expect("contract parsed");
1710 assert_eq!(c.version, "1.0.0");
1711 assert_eq!(c.on_breach, faucet_core::OnBreach::Warn);
1712 assert_eq!(c.fields.len(), 2);
1713 }
1714
1715 #[test]
1716 fn parses_dlq_block_with_defaults() {
1717 let yaml = r#"
1718version: 1
1719pipeline:
1720 source: { type: rest, config: {} }
1721 sink: { type: jsonl, config: { path: ./o.jsonl } }
1722 dlq:
1723 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1724"#;
1725 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1726 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1727 assert_eq!(dlq.sink.kind, "jsonl");
1728 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1729 assert!(dlq.max_failures_per_page.is_none());
1730 assert!(dlq.max_failures_total.is_none());
1731 assert!(dlq.include_original_payload);
1732 }
1733
1734 #[test]
1735 fn parses_dlq_block_with_dlq_all_and_budgets() {
1736 let yaml = r#"
1737version: 1
1738pipeline:
1739 source: { type: rest, config: {} }
1740 sink: { type: jsonl, config: { path: ./o.jsonl } }
1741 dlq:
1742 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1743 on_batch_error: dlq_all
1744 max_failures_per_page: 100
1745 max_failures_total: 10000
1746"#;
1747 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1748 let dlq = cfg.pipeline.dlq.unwrap();
1749 assert_eq!(dlq.sink.kind, "kafka");
1750 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1751 assert_eq!(dlq.max_failures_per_page, Some(100));
1752 assert_eq!(dlq.max_failures_total, Some(10000));
1753 }
1754
1755 #[test]
1756 fn matrix_row_dlq_null_disables_inherited_dlq() {
1757 let yaml = r#"
1758version: 1
1759pipeline:
1760 source: { type: rest, config: {} }
1761 sink: { type: jsonl, config: { path: ./o.jsonl } }
1762 dlq:
1763 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1764matrix:
1765 - id: a
1766 - id: b
1767 dlq: null
1768"#;
1769 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1770 assert!(cfg.matrix[0].dlq.is_none());
1771 assert_eq!(cfg.matrix[1].dlq, Some(None));
1772 }
1773
1774 #[test]
1775 fn matrix_row_dlq_object_replaces_inherited_dlq() {
1776 let yaml = r#"
1777version: 1
1778pipeline:
1779 source: { type: rest, config: {} }
1780 sink: { type: jsonl, config: { path: ./o.jsonl } }
1781 dlq:
1782 sink: { type: jsonl, config: { path: ./base.jsonl } }
1783matrix:
1784 - id: a
1785 dlq:
1786 sink: { type: jsonl, config: { path: ./a.jsonl } }
1787 on_batch_error: dlq_all
1788"#;
1789 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1790 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1791 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1792 let sink_path = row_dlq.sink.config.get("path").unwrap();
1793 assert_eq!(sink_path, "./a.jsonl");
1794 }
1795
1796 #[test]
1797 fn parses_named_sources_and_sinks() {
1798 let yaml = r#"
1799version: 1
1800pipeline:
1801 sources:
1802 users_api:
1803 type: rest
1804 config: { base_url: https://api.example.com }
1805 posts_api:
1806 type: rest
1807 config: { base_url: https://api.example.com }
1808 sinks:
1809 warehouse:
1810 type: postgres
1811 config: { connection_url: "postgres://x" }
1812"#;
1813 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1814 assert!(cfg.pipeline.source.is_none());
1815 assert!(cfg.pipeline.sink.is_none());
1816 assert_eq!(cfg.pipeline.sources.len(), 2);
1817 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1818 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1819 }
1820
1821 #[test]
1822 fn legacy_singular_source_still_parses() {
1823 let yaml = r#"
1824version: 1
1825pipeline:
1826 source: { type: rest, config: {} }
1827 sink: { type: jsonl, config: { path: ./o.jsonl } }
1828"#;
1829 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1830 assert!(cfg.pipeline.source.is_some());
1831 assert!(cfg.pipeline.sink.is_some());
1832 assert!(cfg.pipeline.sources.is_empty());
1833 assert!(cfg.pipeline.sinks.is_empty());
1834 }
1835
1836 #[test]
1837 fn parses_matrix_row_with_ref_field() {
1838 let yaml = r#"
1839version: 1
1840pipeline:
1841 source: { type: rest, config: {} }
1842 sink: { type: jsonl, config: { path: ./o.jsonl } }
1843matrix:
1844 - id: load_users
1845 source:
1846 ref: users_api
1847 config: { path: /v1/users }
1848"#;
1849 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1850 let src = cfg.matrix[0].source.as_ref().unwrap();
1851 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1852 assert_eq!(src.kind, None);
1853 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1854 }
1855
1856 #[test]
1857 fn parses_top_level_vars_block() {
1858 let yaml = r#"
1859version: 1
1860vars:
1861 api_base: https://api.example.com
1862 api_token_env: API_TOKEN
1863pipeline:
1864 source: { type: rest, config: {} }
1865 sink: { type: jsonl, config: { path: ./o.jsonl } }
1866"#;
1867 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1868 let vars = cfg.vars.as_ref().unwrap();
1869 assert_eq!(vars["api_base"], "https://api.example.com");
1870 assert_eq!(vars["api_token_env"], "API_TOKEN");
1871 }
1872
1873 #[test]
1874 fn vars_block_is_optional() {
1875 let yaml = r#"
1876version: 1
1877pipeline:
1878 source: { type: rest, config: {} }
1879 sink: { type: jsonl, config: { path: ./o.jsonl } }
1880"#;
1881 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1882 assert!(cfg.vars.is_none());
1883 }
1884
1885 #[test]
1886 fn from_path_resolves_vars_at_load() {
1887 let dir = tempfile::tempdir().unwrap();
1888 let path = dir.path().join("pipeline.yaml");
1889 std::fs::write(
1890 &path,
1891 r#"
1892version: 1
1893vars:
1894 base: https://api.example.com
1895pipeline:
1896 source: { type: rest, config: { url: "${vars.base}/v1" } }
1897 sink: { type: jsonl, config: { path: ./o.jsonl } }
1898"#,
1899 )
1900 .unwrap();
1901 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1902 assert_eq!(
1903 cfg.pipeline.source.as_ref().unwrap().config["url"],
1904 "https://api.example.com/v1"
1905 );
1906 }
1907
1908 #[test]
1909 fn sync_from_path_errors_on_secret_directive() {
1910 let dir = tempfile::tempdir().unwrap();
1911 let path = dir.path().join("p.yaml");
1912 std::fs::write(
1913 &path,
1914 r#"
1915version: 1
1916pipeline:
1917 source: { type: rest, config: { url: "${vault:secret/x}" } }
1918 sink: { type: jsonl, config: { path: ./o.jsonl } }
1919"#,
1920 )
1921 .unwrap();
1922 match PipelineConfig::from_path(&path, None).unwrap_err() {
1923 CliError::SecretsRequireAsyncLoad => {}
1924 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1925 }
1926 }
1927
1928 #[test]
1929 fn from_value_accepts_v1_and_resolves_refs() {
1930 let v = serde_json::json!({
1931 "version": 1,
1932 "vars": { "out": "resolved.jsonl" },
1933 "pipeline": {
1934 "source": { "type": "csv", "config": { "path": "x.csv" } },
1935 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1936 }
1937 });
1938 let cfg = PipelineConfig::from_value(v).unwrap();
1939 assert_eq!(cfg.version, 1);
1940 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1942 }
1943
1944 #[test]
1945 fn from_value_rejects_non_v1() {
1946 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1948 let err = PipelineConfig::from_value(v).unwrap_err();
1949 match err {
1950 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1951 other => panic!("expected ParseConfig, got {other:?}"),
1952 }
1953 }
1954
1955 #[tokio::test]
1956 async fn async_from_path_loads_without_secrets() {
1957 let dir = tempfile::tempdir().unwrap();
1958 let path = dir.path().join("p.yaml");
1959 std::fs::write(
1960 &path,
1961 r#"
1962version: 1
1963pipeline:
1964 source: { type: rest, config: { base_url: https://x } }
1965 sink: { type: jsonl, config: { path: ./o.jsonl } }
1966"#,
1967 )
1968 .unwrap();
1969 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1970 assert_eq!(cfg.version, 1);
1971 }
1972
1973 #[cfg(feature = "lineage")]
1974 #[test]
1975 fn parses_lineage_block() {
1976 let yaml = r#"
1977version: 1
1978lineage:
1979 namespace: prod
1980 transport: { type: file, config: { path: /tmp/ol.jsonl } }
1981pipeline:
1982 source: { type: rest, config: {} }
1983 sink: { type: jsonl, config: { path: ./o.jsonl } }
1984"#;
1985 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1986 let l = cfg.lineage.expect("lineage parsed");
1987 assert_eq!(l.namespace, "prod");
1988 }
1989
1990 #[test]
1991 fn from_path_resolves_extends_and_profile() {
1992 let dir = tempfile::tempdir().unwrap();
1993 std::fs::write(
1994 dir.path().join("base.yaml"),
1995 "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",
1996 )
1997 .unwrap();
1998 let app = dir.path().join("app.yaml");
1999 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
2000
2001 let cfg = PipelineConfig::from_path(&app, None).unwrap();
2003 assert_eq!(
2004 cfg.pipeline.sink.as_ref().unwrap().config["path"],
2005 "base.jsonl"
2006 );
2007
2008 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
2010 assert_eq!(
2011 cfg.pipeline.sink.as_ref().unwrap().config["path"],
2012 "prod.jsonl"
2013 );
2014 }
2015
2016 #[test]
2017 fn from_value_rejects_extends_with_composition_hint() {
2018 let v = serde_json::json!({
2020 "version": 1,
2021 "extends": "base.yaml",
2022 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
2023 });
2024 let err = PipelineConfig::from_value(v).unwrap_err();
2025 let msg = err.to_string();
2026 assert!(
2027 msg.contains("composition"),
2028 "expected composition hint, got: {msg}"
2029 );
2030 }
2031
2032 #[test]
2033 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
2034 let yaml = r#"
2036version: 1
2037pipeline:
2038 source: { type: rest, config: {} }
2039 sink: { type: jsonl, config: { path: ./o.jsonl } }
2040"#;
2041 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2042 assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
2043
2044 let yaml2 = r#"
2046version: 1
2047delivery: exactly_once
2048pipeline:
2049 source: { type: rest, config: {} }
2050 sink: { type: jsonl, config: { path: ./o.jsonl } }
2051"#;
2052 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
2053 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
2054
2055 let yaml3 = r#"
2057version: 1
2058delivery: at_least_once
2059pipeline:
2060 source: { type: rest, config: {} }
2061 sink: { type: jsonl, config: { path: ./o.jsonl } }
2062matrix:
2063 - id: a
2064 - id: b
2065 delivery: exactly_once
2066"#;
2067 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
2068 assert_eq!(cfg3.matrix[0].delivery, None);
2069 assert_eq!(
2070 cfg3.matrix[1].delivery,
2071 Some(faucet_core::DeliveryMode::ExactlyOnce)
2072 );
2073 }
2074
2075 #[test]
2076 fn resilience_spec_parses_and_builds_policy() {
2077 let yaml = r#"
2078version: 1
2079pipeline:
2080 source: { type: rest, config: { base_url: "https://x" } }
2081 sink: { type: stdout, config: {} }
2082resilience:
2083 retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
2084 retry_on: [http_5xx, timeout]
2085 circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
2086 poison: { max_row_attempts: 2, action: dlq }
2087"#;
2088 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2089 let spec = cfg.resilience.unwrap();
2090 let policy = spec.to_policy().unwrap();
2091 assert_eq!(policy.retry.max_attempts, 4);
2092 assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
2093 assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
2094 }
2095
2096 #[test]
2097 fn resilience_rejects_zero_max_attempts() {
2098 let yaml = r#"
2099version: 1
2100pipeline:
2101 source: { type: rest, config: { base_url: "https://x" } }
2102 sink: { type: stdout, config: {} }
2103resilience: { retry: { max_attempts: 0 } }
2104"#;
2105 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2106 let err = cfg.resilience.unwrap().to_policy().unwrap_err();
2107 assert!(err.to_string().contains("max_attempts"));
2108 }
2109
2110 #[test]
2111 fn observability_parses_otel_block() {
2112 let yaml = r#"
2113version: 1
2114pipeline:
2115 source: { type: rest, config: { base_url: "http://x" } }
2116 sink: { type: stdout, config: {} }
2117observability:
2118 otel:
2119 endpoint: http://collector:4317
2120 protocol: grpc
2121 export: [traces, metrics]
2122"#;
2123 let cfg = parse_with_extension(yaml, "yaml").unwrap();
2124 let otel = cfg.observability.unwrap().otel.unwrap();
2125 assert_eq!(otel.endpoint, "http://collector:4317");
2126 }
2127
2128 #[test]
2129 fn otel_validation_rejects_bad_ratio() {
2130 let yaml = r#"
2131version: 1
2132pipeline:
2133 source: { type: rest, config: { base_url: "http://x" } }
2134 sink: { type: stdout, config: {} }
2135observability:
2136 otel:
2137 sample_ratio: 9.0
2138"#;
2139 let err = parse_with_extension(yaml, "yaml").unwrap_err();
2140 assert!(format!("{err}").contains("sample_ratio"));
2141 }
2142}