1use crate::error::{CliError, CliResult};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37
38#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
40#[serde(deny_unknown_fields)]
41pub struct PipelineConfig {
42 #[serde(default = "default_version")]
44 pub version: u32,
45
46 #[serde(default)]
48 pub name: Option<String>,
49
50 #[serde(default)]
54 pub vars: Option<HashMap<String, Value>>,
55
56 #[serde(default)]
61 pub auth: Option<HashMap<String, Value>>,
62
63 pub pipeline: PipelineSpec,
65
66 #[serde(default)]
69 pub matrix: Vec<MatrixRow>,
70
71 #[serde(default)]
73 pub execution: Option<ExecutionSpec>,
74
75 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub selection: Option<SelectionSpec>,
81
82 #[serde(default)]
84 pub observability: Option<ObservabilitySpec>,
85
86 #[serde(default)]
91 pub delivery: faucet_core::DeliveryMode,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub resilience: Option<ResilienceSpec>,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub sla: Option<crate::sla::SlaSpec>,
106
107 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub shard: Option<ShardingSpec>,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub replication: Option<crate::replication::spec::ReplicationSpec>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub backfill: Option<crate::backfill::BackfillSpec>,
126
127 #[cfg(feature = "schedule")]
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
132
133 #[cfg(feature = "lineage")]
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub lineage: Option<faucet_lineage::LineageConfig>,
137
138 #[cfg(feature = "catalog")]
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub catalog: Option<crate::catalog::CatalogSpec>,
146
147 #[cfg(feature = "notify")]
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub notifications: Vec<crate::notify::NotificationSpec>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162pub struct PipelineSpec {
163 #[serde(default)]
166 pub source: Option<ConnectorSpec>,
167
168 #[serde(default)]
170 pub sink: Option<ConnectorSpec>,
171
172 #[serde(default)]
174 pub sources: HashMap<String, ConnectorSpec>,
175
176 #[serde(default)]
178 pub sinks: HashMap<String, ConnectorSpec>,
179
180 #[serde(default)]
181 pub transforms: Vec<TransformSpec>,
182 #[serde(default)]
183 pub state: Option<StateStoreSpec>,
184 #[serde(default)]
185 pub dlq: Option<DlqSpec>,
186
187 #[cfg(feature = "quality")]
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub quality: Option<faucet_core::QualitySpec>,
191
192 #[cfg(feature = "contract")]
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub contract: Option<faucet_core::ContractSpec>,
198
199 #[cfg(feature = "masking")]
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub masking: Option<faucet_core::MaskingSpec>,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub schema: Option<faucet_core::SchemaDriftSpec>,
212
213 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
218 pub nodes: HashMap<String, NodeSpec>,
219
220 #[serde(default, skip_serializing_if = "Vec::is_empty")]
224 pub edges: Vec<EdgeSpec>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
236#[serde(tag = "kind", rename_all = "lowercase")]
237pub enum NodeSpec {
238 Source {
240 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
242 template: Option<String>,
243 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
245 kind: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 config: Option<Value>,
249 },
250 Sink {
252 #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
254 template: Option<String>,
255 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
257 kind: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 config: Option<Value>,
261 },
262 Transform {
264 #[serde(default)]
266 transforms: Vec<TransformSpec>,
267 },
268 Tee {
270 #[serde(default = "default_channel_capacity")]
272 channel_capacity: usize,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 fanout: Option<usize>,
276 },
277 Merge,
279 Join(JoinSpec),
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
285#[serde(deny_unknown_fields)]
286pub struct JoinSpec {
287 #[serde(default)]
289 pub mode: faucet_core::JoinMode,
290 pub build: JoinSide,
292 pub probe: JoinSide,
294 #[serde(default)]
296 pub project: Vec<faucet_core::Projection>,
297 #[serde(default)]
299 pub on_missing: Value,
300 #[serde(default)]
302 pub on_duplicate: faucet_core::OnDuplicate,
303 #[serde(default)]
305 pub on_collision: faucet_core::OnCollision,
306 #[serde(default)]
308 pub key_normalize: faucet_core::KeyNormalize,
309 #[serde(default = "default_max_build_records")]
311 pub max_build_records: usize,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
316#[serde(deny_unknown_fields)]
317pub struct JoinSide {
318 pub edge: String,
320 pub key: String,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
326#[serde(deny_unknown_fields)]
327pub struct EdgeSpec {
328 pub from: String,
330 pub to: String,
332 #[serde(rename = "as", default, skip_serializing_if = "Option::is_none")]
334 pub label: Option<String>,
335}
336
337fn default_channel_capacity() -> usize {
338 faucet_core::topology::DEFAULT_CHANNEL_CAPACITY
339}
340
341fn default_max_build_records() -> usize {
342 faucet_core::join::DEFAULT_MAX_BUILD_RECORDS
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
350#[serde(deny_unknown_fields)]
351pub struct ConnectorSpec {
352 #[serde(rename = "type")]
355 pub kind: String,
356
357 #[serde(default = "empty_object")]
360 pub config: Value,
361
362 #[serde(default)]
366 pub transforms: Option<Vec<TransformSpec>>,
367
368 #[serde(default = "default_true")]
372 pub inherit_transforms: bool,
373
374 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub status: Option<SourceStatus>,
380
381 #[serde(default, skip_serializing_if = "Vec::is_empty")]
386 pub tags: Vec<String>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
396#[serde(deny_unknown_fields)]
397pub struct PartialConnector {
398 #[serde(default)]
401 pub r#ref: Option<String>,
402 #[serde(rename = "type", default)]
404 pub kind: Option<String>,
405 #[serde(default)]
407 pub config: Option<Value>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub status: Option<SourceStatus>,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
417#[serde(deny_unknown_fields)]
418pub struct TransformSpec {
419 #[serde(rename = "type")]
424 pub kind: String,
425
426 #[serde(default = "empty_object")]
428 pub config: Value,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
433#[serde(deny_unknown_fields)]
434pub struct StateStoreSpec {
435 #[serde(rename = "type")]
437 pub kind: String,
438
439 #[serde(default = "empty_object")]
441 pub config: Value,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
446#[serde(deny_unknown_fields)]
447pub struct MatrixRow {
448 #[serde(default)]
451 pub id: Option<String>,
452
453 #[serde(default)]
455 pub parent: Option<String>,
456
457 #[serde(default)]
462 pub depends_on: Vec<String>,
463
464 #[serde(default = "default_parent_key")]
467 pub parent_key: String,
468
469 #[serde(default)]
471 pub source: Option<PartialConnector>,
472
473 #[serde(default)]
475 pub sink: Option<PartialConnector>,
476
477 #[serde(default)]
482 pub transforms: Option<Vec<TransformSpec>>,
483
484 #[serde(default = "default_true")]
487 pub inherit_transforms: bool,
488
489 #[serde(default)]
491 pub state: Option<StateStoreSpec>,
492
493 #[serde(default, deserialize_with = "deserialize_dlq_override")]
498 pub dlq: Option<Option<DlqSpec>>,
499
500 #[serde(default)]
502 pub delivery: Option<faucet_core::DeliveryMode>,
503
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
511 pub tags: Vec<String>,
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, Default)]
519#[serde(rename_all = "snake_case")]
520pub enum SourceStatus {
521 Mandatory,
523 #[default]
525 Active,
526 Available,
528 Draft,
530 Archived,
532}
533
534impl SourceStatus {
535 pub const ALL: [SourceStatus; 5] = [
537 SourceStatus::Mandatory,
538 SourceStatus::Active,
539 SourceStatus::Available,
540 SourceStatus::Draft,
541 SourceStatus::Archived,
542 ];
543
544 pub fn as_str(self) -> &'static str {
546 match self {
547 SourceStatus::Mandatory => "mandatory",
548 SourceStatus::Active => "active",
549 SourceStatus::Available => "available",
550 SourceStatus::Draft => "draft",
551 SourceStatus::Archived => "archived",
552 }
553 }
554
555 pub fn parse(s: &str) -> Option<Self> {
558 SourceStatus::ALL.into_iter().find(|v| v.as_str() == s)
559 }
560
561 pub fn default_eligible(self) -> bool {
563 matches!(self, SourceStatus::Mandatory | SourceStatus::Active)
564 }
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
573#[serde(rename_all = "snake_case")]
574pub enum IncludeParents {
575 #[default]
578 Off,
579 Eligible,
582 All,
585}
586
587impl IncludeParents {
588 pub fn as_str(self) -> &'static str {
590 match self {
591 IncludeParents::Off => "off",
592 IncludeParents::Eligible => "eligible",
593 IncludeParents::All => "all",
594 }
595 }
596
597 pub fn parse(s: &str) -> Option<Self> {
599 match s {
600 "off" => Some(IncludeParents::Off),
601 "eligible" => Some(IncludeParents::Eligible),
602 "all" => Some(IncludeParents::All),
603 _ => None,
604 }
605 }
606}
607
608#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
611#[serde(deny_unknown_fields)]
612pub struct SelectionSpec {
613 #[serde(default)]
615 pub include_parents: IncludeParents,
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
620#[serde(deny_unknown_fields)]
621pub struct ExecutionSpec {
622 #[serde(default)]
626 pub max_concurrent: Option<usize>,
627
628 #[serde(default)]
630 pub on_error: OnError,
631
632 #[serde(default)]
634 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
639#[serde(deny_unknown_fields)]
640pub struct ShardingSpec {
641 pub count: usize,
646}
647
648#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
650#[serde(rename_all = "lowercase")]
651pub enum OnError {
652 #[default]
654 Continue,
655 Stop,
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
661pub struct ObservabilitySpec {
662 #[serde(default)]
664 pub prometheus: Option<PrometheusSpec>,
665
666 #[serde(default)]
668 pub tracing: Option<TracingSpec>,
669
670 #[serde(default)]
672 pub otel: Option<OtelSpec>,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
677pub struct PrometheusSpec {
678 pub listen: String,
680
681 #[serde(default)]
684 pub buckets: Option<Vec<f64>>,
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
689pub struct TracingSpec {
690 #[serde(default)]
693 pub level: Option<String>,
694}
695
696#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
698pub struct OtelSpec {
699 #[serde(default)]
702 pub endpoint: String,
703 #[serde(default)]
705 pub protocol: faucet_core::OtelProtocol,
706 #[serde(default)]
708 pub headers: std::collections::HashMap<String, String>,
709 #[serde(default = "default_otel_ratio")]
711 pub sample_ratio: f64,
712 #[serde(default = "default_otel_export")]
714 pub export: Vec<faucet_core::OtelSignal>,
715 #[serde(default = "default_otel_service")]
717 pub service_name: String,
718 #[serde(default = "default_otel_timeout")]
720 pub timeout_secs: u64,
721 #[serde(default = "default_otel_interval")]
723 pub metric_interval_secs: u64,
724}
725
726fn default_otel_ratio() -> f64 {
727 1.0
728}
729fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
730 vec![
731 faucet_core::OtelSignal::Traces,
732 faucet_core::OtelSignal::Metrics,
733 ]
734}
735fn default_otel_service() -> String {
736 "faucet".to_string()
737}
738fn default_otel_timeout() -> u64 {
739 10
740}
741fn default_otel_interval() -> u64 {
742 60
743}
744
745impl OtelSpec {
746 pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
748 let cfg = faucet_core::OtelConfig {
749 endpoint: self.endpoint.clone(),
750 protocol: self.protocol,
751 headers: self.headers.clone(),
752 sample_ratio: self.sample_ratio,
753 export: self.export.clone(),
754 service_name: self.service_name.clone(),
755 timeout_secs: self.timeout_secs,
756 metric_interval_secs: self.metric_interval_secs,
757 };
758 cfg.validate()?;
759 Ok(cfg)
760 }
761}
762
763#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
767#[serde(rename_all = "snake_case")]
768pub enum OnBatchErrorSpec {
769 #[default]
770 Propagate,
771 DlqAll,
772}
773
774#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
776#[serde(deny_unknown_fields)]
777pub struct DlqSpec {
778 pub sink: ConnectorSpec,
779 #[serde(default)]
780 pub on_batch_error: OnBatchErrorSpec,
781 #[serde(default)]
782 pub max_failures_per_page: Option<usize>,
783 #[serde(default)]
784 pub max_failures_total: Option<usize>,
785 #[serde(default = "default_true")]
786 pub include_original_payload: bool,
787}
788
789#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
791#[serde(deny_unknown_fields)]
792pub struct ResilienceSpec {
793 #[serde(default)]
796 pub retry: RetrySpec,
797 #[serde(default)]
799 pub retry_on: Option<Vec<faucet_core::RetryClass>>,
800 #[serde(default)]
802 pub circuit_breaker: Option<CircuitBreakerSpec>,
803 #[serde(default)]
805 pub poison: Option<PoisonSpec>,
806}
807
808#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
810#[serde(deny_unknown_fields)]
811pub struct RetrySpec {
812 #[serde(default = "default_max_attempts")]
814 pub max_attempts: u32,
815 #[serde(default)]
817 pub backoff: BackoffSpec,
818 #[serde(default = "default_base_ms")]
820 pub base_ms: u64,
821 #[serde(default = "default_max_ms")]
823 pub max_ms: u64,
824 #[serde(default = "default_true")]
826 pub jitter: bool,
827}
828
829impl Default for RetrySpec {
830 fn default() -> Self {
831 Self {
832 max_attempts: default_max_attempts(),
833 backoff: BackoffSpec::default(),
834 base_ms: default_base_ms(),
835 max_ms: default_max_ms(),
836 jitter: true,
837 }
838 }
839}
840
841#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
843#[serde(rename_all = "snake_case")]
844pub enum BackoffSpec {
845 None,
847 Fixed,
849 #[default]
851 Exponential,
852}
853
854#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
856#[serde(deny_unknown_fields)]
857pub struct CircuitBreakerSpec {
858 pub consecutive_failures: u32,
860 pub cooldown_secs: u64,
862}
863
864#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
866#[serde(deny_unknown_fields)]
867pub struct PoisonSpec {
868 pub max_row_attempts: u32,
870 #[serde(default)]
872 pub action: PoisonActionSpec,
873}
874
875#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
878#[serde(rename_all = "snake_case")]
879pub enum PoisonActionSpec {
880 #[default]
882 Dlq,
883 Drop,
885 Fail,
887}
888
889fn default_max_attempts() -> u32 {
890 5
891}
892fn default_base_ms() -> u64 {
893 200
894}
895fn default_max_ms() -> u64 {
896 30_000
897}
898
899impl ResilienceSpec {
900 pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
902 use crate::error::CliError;
903 if self.retry.max_attempts < 1 {
904 return Err(CliError::Config(
905 "resilience.retry.max_attempts must be >= 1".into(),
906 ));
907 }
908 if self.retry.base_ms > self.retry.max_ms {
909 return Err(CliError::Config(
910 "resilience.retry.base_ms must be <= max_ms".into(),
911 ));
912 }
913 let retry_on = match &self.retry_on {
914 Some(v) if v.is_empty() => {
915 return Err(CliError::Config(
916 "resilience.retry_on must not be empty".into(),
917 ));
918 }
919 Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
920 None => faucet_core::RetryClassSet::default(),
921 };
922 let backoff = match self.retry.backoff {
923 BackoffSpec::None => faucet_core::BackoffKind::None,
924 BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
925 BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
926 };
927 let circuit_breaker = match self.circuit_breaker {
928 Some(cb) if cb.consecutive_failures < 1 => {
929 return Err(CliError::Config(
930 "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
931 ));
932 }
933 Some(cb) => Some(faucet_core::CircuitBreakerConfig {
934 consecutive_failures: cb.consecutive_failures,
935 cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
936 }),
937 None => None,
938 };
939 let poison = match self.poison {
940 Some(p) if p.max_row_attempts < 1 => {
941 return Err(CliError::Config(
942 "resilience.poison.max_row_attempts must be >= 1".into(),
943 ));
944 }
945 Some(p) => Some(faucet_core::PoisonPolicy {
946 max_row_attempts: p.max_row_attempts,
947 action: match p.action {
948 PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
949 PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
950 PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
951 },
952 }),
953 None => None,
954 };
955 Ok(faucet_core::ResiliencePolicy {
956 retry: faucet_core::RetryPolicy {
957 max_attempts: self.retry.max_attempts,
958 backoff,
959 base: std::time::Duration::from_millis(self.retry.base_ms),
960 max: std::time::Duration::from_millis(self.retry.max_ms),
961 jitter: self.retry.jitter,
962 retry_on,
963 },
964 circuit_breaker,
965 poison,
966 })
967 }
968}
969
970fn default_true() -> bool {
971 true
972}
973
974fn default_version() -> u32 {
975 1
976}
977fn default_parent_key() -> String {
978 "id".to_owned()
979}
980fn empty_object() -> Value {
981 Value::Object(Default::default())
982}
983
984fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
985where
986 D: serde::Deserializer<'de>,
987{
988 Option::<DlqSpec>::deserialize(deserializer).map(Some)
989}
990
991fn interpolate_document(text: &str, path: &Path) -> CliResult<String> {
1005 use crate::interpolate::interpolate_value;
1006 let ext = path
1007 .extension()
1008 .and_then(|e| e.to_str())
1009 .map(str::to_ascii_lowercase);
1010 match ext.as_deref() {
1011 Some("yaml" | "yml") => {
1012 let mut value: serde_json::Value =
1013 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1014 path: path.to_path_buf(),
1015 message: friendly_parse_error(&e.to_string()),
1016 })?;
1017 interpolate_value(&mut value)?;
1018 serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
1019 path: path.to_path_buf(),
1020 message: e.to_string(),
1021 })
1022 }
1023 Some("json") => {
1024 let mut value: serde_json::Value =
1025 serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1026 path: path.to_path_buf(),
1027 message: friendly_parse_error(&e.to_string()),
1028 })?;
1029 interpolate_value(&mut value)?;
1030 serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
1031 path: path.to_path_buf(),
1032 message: e.to_string(),
1033 })
1034 }
1035 _ => Err(CliError::UnknownExtension {
1036 path: path.to_path_buf(),
1037 }),
1038 }
1039}
1040
1041impl PipelineConfig {
1042 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1055 let path = path.as_ref();
1056 let composed = crate::compose::compose(path, profile)?;
1057 let interpolated = interpolate_document(&composed, path)?;
1058 let cfg = Self::from_text(&interpolated, path)?;
1059 crate::secrets::ensure_no_secret_directives(&cfg)?;
1062 Ok(cfg)
1063 }
1064
1065 pub fn from_path_tolerating_secrets(
1069 path: impl AsRef<Path>,
1070 profile: Option<&str>,
1071 ) -> CliResult<Self> {
1072 let path = path.as_ref();
1073 let composed = crate::compose::compose(path, profile)?;
1074 let interpolated = interpolate_document(&composed, path)?;
1075 Self::from_text(&interpolated, path)
1076 }
1077
1078 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
1082 let path = path.as_ref();
1083 let composed = crate::compose::compose(path, profile)?;
1084 let interpolated = interpolate_document(&composed, path)?;
1085 let mut cfg = Self::from_text(&interpolated, path)?;
1086 crate::secrets::resolve_secrets(&mut cfg).await?;
1087 Ok(cfg)
1088 }
1089
1090 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
1093 let ext = path
1094 .extension()
1095 .and_then(|e| e.to_str())
1096 .map(str::to_ascii_lowercase);
1097 let cfg: PipelineConfig = match ext.as_deref() {
1098 Some("yaml" | "yml") => {
1099 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
1100 path: path.to_path_buf(),
1101 message: friendly_parse_error(&e.to_string()),
1102 })?
1103 }
1104 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
1105 path: path.to_path_buf(),
1106 message: friendly_parse_error(&e.to_string()),
1107 })?,
1108 _ => {
1109 return Err(CliError::UnknownExtension {
1110 path: path.to_path_buf(),
1111 });
1112 }
1113 };
1114 Self::finish(cfg, path)
1115 }
1116
1117 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
1125 let synthetic = Path::new("<submitted>");
1126 let cfg: PipelineConfig =
1127 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
1128 path: synthetic.to_path_buf(),
1129 message: friendly_parse_error(&e.to_string()),
1130 })?;
1131 Self::finish(cfg, synthetic)
1132 }
1133
1134 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
1136 if cfg.version != 1 {
1137 return Err(CliError::ParseConfig {
1138 path: path.to_path_buf(),
1139 message: format!(
1140 "unsupported pipeline version {}, only version 1 is recognised",
1141 cfg.version
1142 ),
1143 });
1144 }
1145 crate::interpolate::resolve_config_refs(&mut cfg)?;
1146 if let Some(obs) = cfg.observability.as_ref()
1147 && let Some(otel) = obs.otel.as_ref()
1148 {
1149 otel.to_core().map_err(CliError::Config)?;
1150 }
1151 Ok(cfg)
1152 }
1153}
1154
1155fn friendly_parse_error(raw: &str) -> String {
1158 let lower = raw.to_ascii_lowercase();
1159 if lower.contains("missing field `pipeline`") {
1160 return format!(
1161 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
1162 );
1163 }
1164 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
1165 return format!(
1166 "{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."
1167 );
1168 }
1169 raw.to_owned()
1170}
1171
1172pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
1175 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
1176 PipelineConfig::from_text(text, &synthetic)
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182 use serde_json::json;
1183
1184 #[test]
1185 fn parses_minimal_pipeline_yaml() {
1186 let yaml = r#"
1187version: 1
1188pipeline:
1189 source:
1190 type: rest
1191 config:
1192 base_url: https://api.example.com
1193 sink:
1194 type: jsonl
1195 config:
1196 path: ./out.jsonl
1197"#;
1198 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1199 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1200 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
1201 assert!(cfg.matrix.is_empty());
1202 assert!(cfg.execution.is_none());
1203 assert!(cfg.pipeline.transforms.is_empty());
1204 assert!(cfg.pipeline.state.is_none());
1205 }
1206
1207 #[test]
1208 fn parses_replication_block() {
1209 let yaml = r#"
1210version: 1
1211pipeline:
1212 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
1213 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1214 state: { type: file, config: { path: ./st } }
1215replication:
1216 mode: snapshot_then_cdc
1217 snapshot:
1218 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
1219"#;
1220 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1221 let r = cfg.replication.expect("replication parsed");
1222 assert_eq!(r.snapshot.source.kind, "postgres");
1223 }
1224
1225 #[test]
1226 fn pipeline_spec_parses_schema_block() {
1227 let yaml = r#"
1228version: 1
1229pipeline:
1230 source:
1231 type: rest
1232 config:
1233 base_url: https://api.example.com
1234 sink:
1235 type: jsonl
1236 config:
1237 path: ./out.jsonl
1238 schema:
1239 on_drift: evolve
1240 allow_type_widening: false
1241"#;
1242 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1243 let schema = cfg.pipeline.schema.expect("schema block parsed");
1244 assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
1245 assert!(!schema.allow_type_widening);
1246 }
1247
1248 #[test]
1249 fn parses_minimal_json() {
1250 let raw = r#"{
1251 "version": 1,
1252 "pipeline": {
1253 "source": {"type": "rest", "config": {}},
1254 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
1255 }
1256 }"#;
1257 let cfg = parse_with_extension(raw, "json").unwrap();
1258 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
1259 }
1260
1261 #[test]
1262 fn parses_matrix_rows_with_partial_overrides() {
1263 let yaml = r#"
1264version: 1
1265pipeline:
1266 source: { type: rest, config: { base_url: https://api.example.com } }
1267 sink: { type: jsonl, config: { path: ./out.jsonl } }
1268matrix:
1269 - id: users
1270 source: { config: { path: /v1/users } }
1271 sink: { config: { path: ./users.jsonl } }
1272 - id: posts
1273 parent: users
1274 parent_key: user_id
1275 source: { config: { path: "/v1/users/${users.id}/posts" } }
1276"#;
1277 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1278 assert_eq!(cfg.matrix.len(), 2);
1279 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
1280 assert!(cfg.matrix[0].parent.is_none());
1281 let users_src = cfg.matrix[0].source.as_ref().unwrap();
1282 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
1283
1284 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
1285 assert_eq!(cfg.matrix[1].parent_key, "user_id");
1286 }
1287
1288 #[test]
1289 fn parent_key_defaults_to_id() {
1290 let yaml = r#"
1291version: 1
1292pipeline:
1293 source: { type: rest, config: {} }
1294 sink: { type: jsonl, config: { path: ./o.jsonl } }
1295matrix:
1296 - { id: users }
1297 - { id: posts, parent: users }
1298"#;
1299 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1300 assert_eq!(cfg.matrix[1].parent_key, "id");
1301 }
1302
1303 #[test]
1304 fn parses_execution_block() {
1305 let yaml = r#"
1306version: 1
1307pipeline:
1308 source: { type: rest, config: {} }
1309 sink: { type: jsonl, config: { path: ./o.jsonl } }
1310execution:
1311 max_concurrent: 8
1312 on_error: stop
1313"#;
1314 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1315 let exec = cfg.execution.unwrap();
1316 assert_eq!(exec.max_concurrent, Some(8));
1317 assert_eq!(exec.on_error, OnError::Stop);
1318 }
1319
1320 #[test]
1321 fn on_error_defaults_to_continue() {
1322 let yaml = r#"
1323version: 1
1324pipeline:
1325 source: { type: rest, config: {} }
1326 sink: { type: jsonl, config: { path: ./o.jsonl } }
1327execution: { max_concurrent: 2 }
1328"#;
1329 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1330 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1331 }
1332
1333 #[test]
1334 fn rejects_old_top_level_source_sink_with_hint() {
1335 let yaml = r#"
1337version: 1
1338source: { type: rest, config: {} }
1339sink: { type: jsonl, config: { path: ./o.jsonl } }
1340"#;
1341 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1342 let msg = err.to_string();
1343 assert!(
1344 msg.contains("pipeline"),
1345 "expected a hint about wrapping in `pipeline:`, got: {msg}"
1346 );
1347 }
1348
1349 #[test]
1350 fn rejects_unknown_extension() {
1351 let text = "version: 1\n";
1352 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1353 assert!(matches!(err, CliError::UnknownExtension { .. }));
1354 }
1355
1356 #[test]
1357 fn rejects_future_version() {
1358 let yaml = r#"
1359version: 99
1360pipeline:
1361 source: { type: rest, config: {} }
1362 sink: { type: jsonl, config: { path: ./x } }
1363"#;
1364 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1365 match err {
1366 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1367 other => panic!("expected ParseConfig, got {other:?}"),
1368 }
1369 }
1370
1371 #[test]
1372 fn transforms_and_state_round_trip() {
1373 let yaml = r#"
1374version: 1
1375pipeline:
1376 source:
1377 type: rest
1378 config: {}
1379 transforms:
1380 - type: snake_case
1381 - type: flatten
1382 config: { separator: "__" }
1383 sink:
1384 type: jsonl
1385 config: { path: "./out.jsonl" }
1386 state:
1387 type: file
1388 config: { path: "./.faucet-state" }
1389"#;
1390 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1391 assert_eq!(cfg.pipeline.transforms.len(), 2);
1392 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1393 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1394 assert_eq!(
1395 cfg.pipeline.transforms[1].config,
1396 json!({"separator": "__"})
1397 );
1398 let state = cfg.pipeline.state.unwrap();
1399 assert_eq!(state.kind, "file");
1400 }
1401
1402 #[test]
1403 fn from_path_interpolates_env_var() {
1404 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1405 let dir = tempfile::tempdir().unwrap();
1406 let path = dir.path().join("pipeline.yaml");
1407 std::fs::write(
1408 &path,
1409 r#"
1410version: 1
1411pipeline:
1412 source:
1413 type: rest
1414 config:
1415 base_url: ${env:FAUCET_CFG_URL}
1416 sink:
1417 type: jsonl
1418 config:
1419 path: ./out.jsonl
1420"#,
1421 )
1422 .unwrap();
1423 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1424 assert_eq!(
1425 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1426 "https://x.example"
1427 );
1428 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1429 }
1430
1431 #[test]
1432 fn observability_block_parses() {
1433 let y = r#"
1434version: 1
1435name: x
1436observability:
1437 prometheus:
1438 listen: "127.0.0.1:9464"
1439 buckets: [0.01, 0.1, 1.0]
1440 tracing:
1441 level: "info"
1442pipeline:
1443 source:
1444 type: rest
1445 config:
1446 base_url: "https://example.com"
1447 path: "/data"
1448 sink:
1449 type: jsonl
1450 config:
1451 path: "/tmp/faucet-test.jsonl"
1452"#;
1453 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1454 let obs = cfg.observability.expect("observability block parsed");
1455 let p = obs.prometheus.expect("prometheus parsed");
1456 assert_eq!(p.listen, "127.0.0.1:9464");
1457 assert_eq!(p.buckets.unwrap().len(), 3);
1458 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1459 }
1460
1461 #[test]
1462 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1463 let dir = tempfile::tempdir().unwrap();
1466 let path = dir.path().join("pipeline.yaml");
1467 std::fs::write(
1468 &path,
1469 r#"
1470version: 1
1471pipeline:
1472 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1473 sink: { type: jsonl, config: { path: ./o.jsonl } }
1474"#,
1475 )
1476 .unwrap();
1477 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1478 assert_eq!(
1479 cfg.pipeline.source.as_ref().unwrap().config["path"],
1480 "/v1/users/${users.id}/posts"
1481 );
1482 }
1483
1484 #[cfg(feature = "schedule")]
1485 #[test]
1486 fn parses_schedule_block() {
1487 let yaml = r#"
1488version: 1
1489schedule:
1490 cron: "0 2 * * *"
1491 timezone: "America/Los_Angeles"
1492 overlap_policy: skip
1493 max_consecutive_failures: 5
1494pipeline:
1495 source: { type: rest, config: {} }
1496 sink: { type: jsonl, config: { path: ./o.jsonl } }
1497"#;
1498 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1499 let s = cfg.schedule.expect("schedule parsed");
1500 assert_eq!(s.cron, "0 2 * * *");
1501 assert_eq!(s.timezone, "America/Los_Angeles");
1502 assert_eq!(s.max_consecutive_failures, Some(5));
1503 }
1504
1505 #[test]
1506 fn execution_spec_parses_adaptive_block() {
1507 let yaml = r#"
1508version: 1
1509pipeline:
1510 source: { type: rest, config: { base_url: https://api.example.com } }
1511 sink: { type: jsonl, config: { path: ./out.jsonl } }
1512execution:
1513 adaptive_batch_size:
1514 enabled: true
1515 min: 200
1516 max: 4000
1517 target_latency_ms: 800
1518"#;
1519 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1520 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1521 assert!(ab.enabled);
1522 assert_eq!(ab.min, 200);
1523 assert_eq!(ab.target_latency_ms, Some(800));
1524 ab.validate().unwrap();
1525 }
1526
1527 #[cfg(feature = "quality")]
1528 #[test]
1529 fn parses_quality_block() {
1530 let yaml = r#"
1531version: 1
1532pipeline:
1533 source: { type: rest, config: { url: "https://x" } }
1534 quality:
1535 record:
1536 - { type: not_null, field: id, on_failure: abort }
1537 sink: { type: stdout, config: {} }
1538"#;
1539 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1540 let q = cfg.pipeline.quality.expect("quality parsed");
1541 assert_eq!(q.record.len(), 1);
1542 }
1543
1544 #[cfg(feature = "contract")]
1545 #[test]
1546 fn parses_contract_block() {
1547 let yaml = r#"
1548version: 1
1549pipeline:
1550 source: { type: rest, config: { url: "https://x" } }
1551 contract:
1552 version: "1.0.0"
1553 on_breach: warn
1554 fields:
1555 - { name: id, type: integer }
1556 - { name: status, type: string, enum: [open, closed] }
1557 sink: { type: stdout, config: {} }
1558"#;
1559 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1560 let c = cfg.pipeline.contract.expect("contract parsed");
1561 assert_eq!(c.version, "1.0.0");
1562 assert_eq!(c.on_breach, faucet_core::OnBreach::Warn);
1563 assert_eq!(c.fields.len(), 2);
1564 }
1565
1566 #[test]
1567 fn parses_dlq_block_with_defaults() {
1568 let yaml = r#"
1569version: 1
1570pipeline:
1571 source: { type: rest, config: {} }
1572 sink: { type: jsonl, config: { path: ./o.jsonl } }
1573 dlq:
1574 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1575"#;
1576 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1577 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1578 assert_eq!(dlq.sink.kind, "jsonl");
1579 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1580 assert!(dlq.max_failures_per_page.is_none());
1581 assert!(dlq.max_failures_total.is_none());
1582 assert!(dlq.include_original_payload);
1583 }
1584
1585 #[test]
1586 fn parses_dlq_block_with_dlq_all_and_budgets() {
1587 let yaml = r#"
1588version: 1
1589pipeline:
1590 source: { type: rest, config: {} }
1591 sink: { type: jsonl, config: { path: ./o.jsonl } }
1592 dlq:
1593 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1594 on_batch_error: dlq_all
1595 max_failures_per_page: 100
1596 max_failures_total: 10000
1597"#;
1598 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1599 let dlq = cfg.pipeline.dlq.unwrap();
1600 assert_eq!(dlq.sink.kind, "kafka");
1601 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1602 assert_eq!(dlq.max_failures_per_page, Some(100));
1603 assert_eq!(dlq.max_failures_total, Some(10000));
1604 }
1605
1606 #[test]
1607 fn matrix_row_dlq_null_disables_inherited_dlq() {
1608 let yaml = r#"
1609version: 1
1610pipeline:
1611 source: { type: rest, config: {} }
1612 sink: { type: jsonl, config: { path: ./o.jsonl } }
1613 dlq:
1614 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1615matrix:
1616 - id: a
1617 - id: b
1618 dlq: null
1619"#;
1620 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1621 assert!(cfg.matrix[0].dlq.is_none());
1622 assert_eq!(cfg.matrix[1].dlq, Some(None));
1623 }
1624
1625 #[test]
1626 fn matrix_row_dlq_object_replaces_inherited_dlq() {
1627 let yaml = r#"
1628version: 1
1629pipeline:
1630 source: { type: rest, config: {} }
1631 sink: { type: jsonl, config: { path: ./o.jsonl } }
1632 dlq:
1633 sink: { type: jsonl, config: { path: ./base.jsonl } }
1634matrix:
1635 - id: a
1636 dlq:
1637 sink: { type: jsonl, config: { path: ./a.jsonl } }
1638 on_batch_error: dlq_all
1639"#;
1640 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1641 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1642 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1643 let sink_path = row_dlq.sink.config.get("path").unwrap();
1644 assert_eq!(sink_path, "./a.jsonl");
1645 }
1646
1647 #[test]
1648 fn parses_named_sources_and_sinks() {
1649 let yaml = r#"
1650version: 1
1651pipeline:
1652 sources:
1653 users_api:
1654 type: rest
1655 config: { base_url: https://api.example.com }
1656 posts_api:
1657 type: rest
1658 config: { base_url: https://api.example.com }
1659 sinks:
1660 warehouse:
1661 type: postgres
1662 config: { connection_url: "postgres://x" }
1663"#;
1664 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1665 assert!(cfg.pipeline.source.is_none());
1666 assert!(cfg.pipeline.sink.is_none());
1667 assert_eq!(cfg.pipeline.sources.len(), 2);
1668 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1669 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1670 }
1671
1672 #[test]
1673 fn legacy_singular_source_still_parses() {
1674 let yaml = r#"
1675version: 1
1676pipeline:
1677 source: { type: rest, config: {} }
1678 sink: { type: jsonl, config: { path: ./o.jsonl } }
1679"#;
1680 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1681 assert!(cfg.pipeline.source.is_some());
1682 assert!(cfg.pipeline.sink.is_some());
1683 assert!(cfg.pipeline.sources.is_empty());
1684 assert!(cfg.pipeline.sinks.is_empty());
1685 }
1686
1687 #[test]
1688 fn parses_matrix_row_with_ref_field() {
1689 let yaml = r#"
1690version: 1
1691pipeline:
1692 source: { type: rest, config: {} }
1693 sink: { type: jsonl, config: { path: ./o.jsonl } }
1694matrix:
1695 - id: load_users
1696 source:
1697 ref: users_api
1698 config: { path: /v1/users }
1699"#;
1700 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1701 let src = cfg.matrix[0].source.as_ref().unwrap();
1702 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1703 assert_eq!(src.kind, None);
1704 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1705 }
1706
1707 #[test]
1708 fn parses_top_level_vars_block() {
1709 let yaml = r#"
1710version: 1
1711vars:
1712 api_base: https://api.example.com
1713 api_token_env: API_TOKEN
1714pipeline:
1715 source: { type: rest, config: {} }
1716 sink: { type: jsonl, config: { path: ./o.jsonl } }
1717"#;
1718 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1719 let vars = cfg.vars.as_ref().unwrap();
1720 assert_eq!(vars["api_base"], "https://api.example.com");
1721 assert_eq!(vars["api_token_env"], "API_TOKEN");
1722 }
1723
1724 #[test]
1725 fn vars_block_is_optional() {
1726 let yaml = r#"
1727version: 1
1728pipeline:
1729 source: { type: rest, config: {} }
1730 sink: { type: jsonl, config: { path: ./o.jsonl } }
1731"#;
1732 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1733 assert!(cfg.vars.is_none());
1734 }
1735
1736 #[test]
1737 fn from_path_resolves_vars_at_load() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let path = dir.path().join("pipeline.yaml");
1740 std::fs::write(
1741 &path,
1742 r#"
1743version: 1
1744vars:
1745 base: https://api.example.com
1746pipeline:
1747 source: { type: rest, config: { url: "${vars.base}/v1" } }
1748 sink: { type: jsonl, config: { path: ./o.jsonl } }
1749"#,
1750 )
1751 .unwrap();
1752 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1753 assert_eq!(
1754 cfg.pipeline.source.as_ref().unwrap().config["url"],
1755 "https://api.example.com/v1"
1756 );
1757 }
1758
1759 #[test]
1760 fn sync_from_path_errors_on_secret_directive() {
1761 let dir = tempfile::tempdir().unwrap();
1762 let path = dir.path().join("p.yaml");
1763 std::fs::write(
1764 &path,
1765 r#"
1766version: 1
1767pipeline:
1768 source: { type: rest, config: { url: "${vault:secret/x}" } }
1769 sink: { type: jsonl, config: { path: ./o.jsonl } }
1770"#,
1771 )
1772 .unwrap();
1773 match PipelineConfig::from_path(&path, None).unwrap_err() {
1774 CliError::SecretsRequireAsyncLoad => {}
1775 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1776 }
1777 }
1778
1779 #[test]
1780 fn from_value_accepts_v1_and_resolves_refs() {
1781 let v = serde_json::json!({
1782 "version": 1,
1783 "vars": { "out": "resolved.jsonl" },
1784 "pipeline": {
1785 "source": { "type": "csv", "config": { "path": "x.csv" } },
1786 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1787 }
1788 });
1789 let cfg = PipelineConfig::from_value(v).unwrap();
1790 assert_eq!(cfg.version, 1);
1791 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1793 }
1794
1795 #[test]
1796 fn from_value_rejects_non_v1() {
1797 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1799 let err = PipelineConfig::from_value(v).unwrap_err();
1800 match err {
1801 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1802 other => panic!("expected ParseConfig, got {other:?}"),
1803 }
1804 }
1805
1806 #[tokio::test]
1807 async fn async_from_path_loads_without_secrets() {
1808 let dir = tempfile::tempdir().unwrap();
1809 let path = dir.path().join("p.yaml");
1810 std::fs::write(
1811 &path,
1812 r#"
1813version: 1
1814pipeline:
1815 source: { type: rest, config: { base_url: https://x } }
1816 sink: { type: jsonl, config: { path: ./o.jsonl } }
1817"#,
1818 )
1819 .unwrap();
1820 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1821 assert_eq!(cfg.version, 1);
1822 }
1823
1824 #[cfg(feature = "lineage")]
1825 #[test]
1826 fn parses_lineage_block() {
1827 let yaml = r#"
1828version: 1
1829lineage:
1830 namespace: prod
1831 transport: { type: file, config: { path: /tmp/ol.jsonl } }
1832pipeline:
1833 source: { type: rest, config: {} }
1834 sink: { type: jsonl, config: { path: ./o.jsonl } }
1835"#;
1836 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1837 let l = cfg.lineage.expect("lineage parsed");
1838 assert_eq!(l.namespace, "prod");
1839 }
1840
1841 #[test]
1842 fn from_path_resolves_extends_and_profile() {
1843 let dir = tempfile::tempdir().unwrap();
1844 std::fs::write(
1845 dir.path().join("base.yaml"),
1846 "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",
1847 )
1848 .unwrap();
1849 let app = dir.path().join("app.yaml");
1850 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1851
1852 let cfg = PipelineConfig::from_path(&app, None).unwrap();
1854 assert_eq!(
1855 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1856 "base.jsonl"
1857 );
1858
1859 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1861 assert_eq!(
1862 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1863 "prod.jsonl"
1864 );
1865 }
1866
1867 #[test]
1868 fn from_value_rejects_extends_with_composition_hint() {
1869 let v = serde_json::json!({
1871 "version": 1,
1872 "extends": "base.yaml",
1873 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1874 });
1875 let err = PipelineConfig::from_value(v).unwrap_err();
1876 let msg = err.to_string();
1877 assert!(
1878 msg.contains("composition"),
1879 "expected composition hint, got: {msg}"
1880 );
1881 }
1882
1883 #[test]
1884 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1885 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_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1894
1895 let yaml2 = r#"
1897version: 1
1898delivery: exactly_once
1899pipeline:
1900 source: { type: rest, config: {} }
1901 sink: { type: jsonl, config: { path: ./o.jsonl } }
1902"#;
1903 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
1904 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
1905
1906 let yaml3 = r#"
1908version: 1
1909delivery: at_least_once
1910pipeline:
1911 source: { type: rest, config: {} }
1912 sink: { type: jsonl, config: { path: ./o.jsonl } }
1913matrix:
1914 - id: a
1915 - id: b
1916 delivery: exactly_once
1917"#;
1918 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
1919 assert_eq!(cfg3.matrix[0].delivery, None);
1920 assert_eq!(
1921 cfg3.matrix[1].delivery,
1922 Some(faucet_core::DeliveryMode::ExactlyOnce)
1923 );
1924 }
1925
1926 #[test]
1927 fn resilience_spec_parses_and_builds_policy() {
1928 let yaml = r#"
1929version: 1
1930pipeline:
1931 source: { type: rest, config: { base_url: "https://x" } }
1932 sink: { type: stdout, config: {} }
1933resilience:
1934 retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
1935 retry_on: [http_5xx, timeout]
1936 circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
1937 poison: { max_row_attempts: 2, action: dlq }
1938"#;
1939 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1940 let spec = cfg.resilience.unwrap();
1941 let policy = spec.to_policy().unwrap();
1942 assert_eq!(policy.retry.max_attempts, 4);
1943 assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
1944 assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
1945 }
1946
1947 #[test]
1948 fn resilience_rejects_zero_max_attempts() {
1949 let yaml = r#"
1950version: 1
1951pipeline:
1952 source: { type: rest, config: { base_url: "https://x" } }
1953 sink: { type: stdout, config: {} }
1954resilience: { retry: { max_attempts: 0 } }
1955"#;
1956 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1957 let err = cfg.resilience.unwrap().to_policy().unwrap_err();
1958 assert!(err.to_string().contains("max_attempts"));
1959 }
1960
1961 #[test]
1962 fn observability_parses_otel_block() {
1963 let yaml = r#"
1964version: 1
1965pipeline:
1966 source: { type: rest, config: { base_url: "http://x" } }
1967 sink: { type: stdout, config: {} }
1968observability:
1969 otel:
1970 endpoint: http://collector:4317
1971 protocol: grpc
1972 export: [traces, metrics]
1973"#;
1974 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1975 let otel = cfg.observability.unwrap().otel.unwrap();
1976 assert_eq!(otel.endpoint, "http://collector:4317");
1977 }
1978
1979 #[test]
1980 fn otel_validation_rejects_bad_ratio() {
1981 let yaml = r#"
1982version: 1
1983pipeline:
1984 source: { type: rest, config: { base_url: "http://x" } }
1985 sink: { type: stdout, config: {} }
1986observability:
1987 otel:
1988 sample_ratio: 9.0
1989"#;
1990 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1991 assert!(format!("{err}").contains("sample_ratio"));
1992 }
1993}