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)]
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)]
77 pub observability: Option<ObservabilitySpec>,
78
79 #[serde(default)]
84 pub delivery: faucet_core::DeliveryMode,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub resilience: Option<ResilienceSpec>,
91
92 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub shard: Option<ShardingSpec>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub replication: Option<crate::replication::spec::ReplicationSpec>,
105
106 #[cfg(feature = "schedule")]
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
111
112 #[cfg(feature = "lineage")]
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub lineage: Option<faucet_lineage::LineageConfig>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct PipelineSpec {
124 #[serde(default)]
127 pub source: Option<ConnectorSpec>,
128
129 #[serde(default)]
131 pub sink: Option<ConnectorSpec>,
132
133 #[serde(default)]
135 pub sources: HashMap<String, ConnectorSpec>,
136
137 #[serde(default)]
139 pub sinks: HashMap<String, ConnectorSpec>,
140
141 #[serde(default)]
142 pub transforms: Vec<TransformSpec>,
143 #[serde(default)]
144 pub state: Option<StateStoreSpec>,
145 #[serde(default)]
146 pub dlq: Option<DlqSpec>,
147
148 #[cfg(feature = "quality")]
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub quality: Option<faucet_core::QualitySpec>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub schema: Option<faucet_core::SchemaDriftSpec>,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
163#[serde(deny_unknown_fields)]
164pub struct ConnectorSpec {
165 #[serde(rename = "type")]
168 pub kind: String,
169
170 #[serde(default = "empty_object")]
173 pub config: Value,
174
175 #[serde(default)]
179 pub transforms: Option<Vec<TransformSpec>>,
180
181 #[serde(default = "default_true")]
185 pub inherit_transforms: bool,
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct PartialConnector {
197 #[serde(default)]
200 pub r#ref: Option<String>,
201 #[serde(rename = "type", default)]
203 pub kind: Option<String>,
204 #[serde(default)]
206 pub config: Option<Value>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
211#[serde(deny_unknown_fields)]
212pub struct TransformSpec {
213 #[serde(rename = "type")]
218 pub kind: String,
219
220 #[serde(default = "empty_object")]
222 pub config: Value,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct StateStoreSpec {
229 #[serde(rename = "type")]
231 pub kind: String,
232
233 #[serde(default = "empty_object")]
235 pub config: Value,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct MatrixRow {
242 #[serde(default)]
245 pub id: Option<String>,
246
247 #[serde(default)]
249 pub parent: Option<String>,
250
251 #[serde(default = "default_parent_key")]
254 pub parent_key: String,
255
256 #[serde(default)]
258 pub source: Option<PartialConnector>,
259
260 #[serde(default)]
262 pub sink: Option<PartialConnector>,
263
264 #[serde(default)]
269 pub transforms: Option<Vec<TransformSpec>>,
270
271 #[serde(default = "default_true")]
274 pub inherit_transforms: bool,
275
276 #[serde(default)]
278 pub state: Option<StateStoreSpec>,
279
280 #[serde(default, deserialize_with = "deserialize_dlq_override")]
285 pub dlq: Option<Option<DlqSpec>>,
286
287 #[serde(default)]
289 pub delivery: Option<faucet_core::DeliveryMode>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
294#[serde(deny_unknown_fields)]
295pub struct ExecutionSpec {
296 #[serde(default)]
300 pub max_concurrent: Option<usize>,
301
302 #[serde(default)]
304 pub on_error: OnError,
305
306 #[serde(default)]
308 pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct ShardingSpec {
315 pub count: usize,
320}
321
322#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
324#[serde(rename_all = "lowercase")]
325pub enum OnError {
326 #[default]
328 Continue,
329 Stop,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
335pub struct ObservabilitySpec {
336 #[serde(default)]
338 pub prometheus: Option<PrometheusSpec>,
339
340 #[serde(default)]
342 pub tracing: Option<TracingSpec>,
343
344 #[serde(default)]
346 pub otel: Option<OtelSpec>,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
351pub struct PrometheusSpec {
352 pub listen: String,
354
355 #[serde(default)]
358 pub buckets: Option<Vec<f64>>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363pub struct TracingSpec {
364 #[serde(default)]
367 pub level: Option<String>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372pub struct OtelSpec {
373 #[serde(default)]
376 pub endpoint: String,
377 #[serde(default)]
379 pub protocol: faucet_core::OtelProtocol,
380 #[serde(default)]
382 pub headers: std::collections::HashMap<String, String>,
383 #[serde(default = "default_otel_ratio")]
385 pub sample_ratio: f64,
386 #[serde(default = "default_otel_export")]
388 pub export: Vec<faucet_core::OtelSignal>,
389 #[serde(default = "default_otel_service")]
391 pub service_name: String,
392 #[serde(default = "default_otel_timeout")]
394 pub timeout_secs: u64,
395 #[serde(default = "default_otel_interval")]
397 pub metric_interval_secs: u64,
398}
399
400fn default_otel_ratio() -> f64 {
401 1.0
402}
403fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
404 vec![
405 faucet_core::OtelSignal::Traces,
406 faucet_core::OtelSignal::Metrics,
407 ]
408}
409fn default_otel_service() -> String {
410 "faucet".to_string()
411}
412fn default_otel_timeout() -> u64 {
413 10
414}
415fn default_otel_interval() -> u64 {
416 60
417}
418
419impl OtelSpec {
420 pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
422 let cfg = faucet_core::OtelConfig {
423 endpoint: self.endpoint.clone(),
424 protocol: self.protocol,
425 headers: self.headers.clone(),
426 sample_ratio: self.sample_ratio,
427 export: self.export.clone(),
428 service_name: self.service_name.clone(),
429 timeout_secs: self.timeout_secs,
430 metric_interval_secs: self.metric_interval_secs,
431 };
432 cfg.validate()?;
433 Ok(cfg)
434 }
435}
436
437#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
441#[serde(rename_all = "snake_case")]
442pub enum OnBatchErrorSpec {
443 #[default]
444 Propagate,
445 DlqAll,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
450#[serde(deny_unknown_fields)]
451pub struct DlqSpec {
452 pub sink: ConnectorSpec,
453 #[serde(default)]
454 pub on_batch_error: OnBatchErrorSpec,
455 #[serde(default)]
456 pub max_failures_per_page: Option<usize>,
457 #[serde(default)]
458 pub max_failures_total: Option<usize>,
459 #[serde(default = "default_true")]
460 pub include_original_payload: bool,
461}
462
463#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
465#[serde(deny_unknown_fields)]
466pub struct ResilienceSpec {
467 #[serde(default)]
470 pub retry: RetrySpec,
471 #[serde(default)]
473 pub retry_on: Option<Vec<faucet_core::RetryClass>>,
474 #[serde(default)]
476 pub circuit_breaker: Option<CircuitBreakerSpec>,
477 #[serde(default)]
479 pub poison: Option<PoisonSpec>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
484#[serde(deny_unknown_fields)]
485pub struct RetrySpec {
486 #[serde(default = "default_max_attempts")]
488 pub max_attempts: u32,
489 #[serde(default)]
491 pub backoff: BackoffSpec,
492 #[serde(default = "default_base_ms")]
494 pub base_ms: u64,
495 #[serde(default = "default_max_ms")]
497 pub max_ms: u64,
498 #[serde(default = "default_true")]
500 pub jitter: bool,
501}
502
503impl Default for RetrySpec {
504 fn default() -> Self {
505 Self {
506 max_attempts: default_max_attempts(),
507 backoff: BackoffSpec::default(),
508 base_ms: default_base_ms(),
509 max_ms: default_max_ms(),
510 jitter: true,
511 }
512 }
513}
514
515#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
517#[serde(rename_all = "snake_case")]
518pub enum BackoffSpec {
519 None,
521 Fixed,
523 #[default]
525 Exponential,
526}
527
528#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
530#[serde(deny_unknown_fields)]
531pub struct CircuitBreakerSpec {
532 pub consecutive_failures: u32,
534 pub cooldown_secs: u64,
536}
537
538#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
540#[serde(deny_unknown_fields)]
541pub struct PoisonSpec {
542 pub max_row_attempts: u32,
544 #[serde(default)]
546 pub action: PoisonActionSpec,
547}
548
549#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
552#[serde(rename_all = "snake_case")]
553pub enum PoisonActionSpec {
554 #[default]
556 Dlq,
557 Drop,
559 Fail,
561}
562
563fn default_max_attempts() -> u32 {
564 5
565}
566fn default_base_ms() -> u64 {
567 200
568}
569fn default_max_ms() -> u64 {
570 30_000
571}
572
573impl ResilienceSpec {
574 pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
576 use crate::error::CliError;
577 if self.retry.max_attempts < 1 {
578 return Err(CliError::Config(
579 "resilience.retry.max_attempts must be >= 1".into(),
580 ));
581 }
582 if self.retry.base_ms > self.retry.max_ms {
583 return Err(CliError::Config(
584 "resilience.retry.base_ms must be <= max_ms".into(),
585 ));
586 }
587 let retry_on = match &self.retry_on {
588 Some(v) if v.is_empty() => {
589 return Err(CliError::Config(
590 "resilience.retry_on must not be empty".into(),
591 ));
592 }
593 Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
594 None => faucet_core::RetryClassSet::default(),
595 };
596 let backoff = match self.retry.backoff {
597 BackoffSpec::None => faucet_core::BackoffKind::None,
598 BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
599 BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
600 };
601 let circuit_breaker = match self.circuit_breaker {
602 Some(cb) if cb.consecutive_failures < 1 => {
603 return Err(CliError::Config(
604 "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
605 ));
606 }
607 Some(cb) => Some(faucet_core::CircuitBreakerConfig {
608 consecutive_failures: cb.consecutive_failures,
609 cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
610 }),
611 None => None,
612 };
613 let poison = match self.poison {
614 Some(p) if p.max_row_attempts < 1 => {
615 return Err(CliError::Config(
616 "resilience.poison.max_row_attempts must be >= 1".into(),
617 ));
618 }
619 Some(p) => Some(faucet_core::PoisonPolicy {
620 max_row_attempts: p.max_row_attempts,
621 action: match p.action {
622 PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
623 PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
624 PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
625 },
626 }),
627 None => None,
628 };
629 Ok(faucet_core::ResiliencePolicy {
630 retry: faucet_core::RetryPolicy {
631 max_attempts: self.retry.max_attempts,
632 backoff,
633 base: std::time::Duration::from_millis(self.retry.base_ms),
634 max: std::time::Duration::from_millis(self.retry.max_ms),
635 jitter: self.retry.jitter,
636 retry_on,
637 },
638 circuit_breaker,
639 poison,
640 })
641 }
642}
643
644fn default_true() -> bool {
645 true
646}
647
648fn default_version() -> u32 {
649 1
650}
651fn default_parent_key() -> String {
652 "id".to_owned()
653}
654fn empty_object() -> Value {
655 Value::Object(Default::default())
656}
657
658fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
659where
660 D: serde::Deserializer<'de>,
661{
662 Option::<DlqSpec>::deserialize(deserializer).map(Some)
663}
664
665fn interpolate_document(text: &str, path: &Path) -> CliResult<String> {
679 use crate::interpolate::interpolate_value;
680 let ext = path
681 .extension()
682 .and_then(|e| e.to_str())
683 .map(str::to_ascii_lowercase);
684 match ext.as_deref() {
685 Some("yaml" | "yml") => {
686 let mut value: serde_json::Value =
687 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
688 path: path.to_path_buf(),
689 message: friendly_parse_error(&e.to_string()),
690 })?;
691 interpolate_value(&mut value)?;
692 serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
693 path: path.to_path_buf(),
694 message: e.to_string(),
695 })
696 }
697 Some("json") => {
698 let mut value: serde_json::Value =
699 serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
700 path: path.to_path_buf(),
701 message: friendly_parse_error(&e.to_string()),
702 })?;
703 interpolate_value(&mut value)?;
704 serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
705 path: path.to_path_buf(),
706 message: e.to_string(),
707 })
708 }
709 _ => Err(CliError::UnknownExtension {
710 path: path.to_path_buf(),
711 }),
712 }
713}
714
715impl PipelineConfig {
716 pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
729 let path = path.as_ref();
730 let composed = crate::compose::compose(path, profile)?;
731 let interpolated = interpolate_document(&composed, path)?;
732 let cfg = Self::from_text(&interpolated, path)?;
733 crate::secrets::ensure_no_secret_directives(&cfg)?;
736 Ok(cfg)
737 }
738
739 pub fn from_path_tolerating_secrets(
743 path: impl AsRef<Path>,
744 profile: Option<&str>,
745 ) -> CliResult<Self> {
746 let path = path.as_ref();
747 let composed = crate::compose::compose(path, profile)?;
748 let interpolated = interpolate_document(&composed, path)?;
749 Self::from_text(&interpolated, path)
750 }
751
752 pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
756 let path = path.as_ref();
757 let composed = crate::compose::compose(path, profile)?;
758 let interpolated = interpolate_document(&composed, path)?;
759 let mut cfg = Self::from_text(&interpolated, path)?;
760 crate::secrets::resolve_secrets(&mut cfg).await?;
761 Ok(cfg)
762 }
763
764 pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
767 let ext = path
768 .extension()
769 .and_then(|e| e.to_str())
770 .map(str::to_ascii_lowercase);
771 let cfg: PipelineConfig = match ext.as_deref() {
772 Some("yaml" | "yml") => {
773 serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
774 path: path.to_path_buf(),
775 message: friendly_parse_error(&e.to_string()),
776 })?
777 }
778 Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
779 path: path.to_path_buf(),
780 message: friendly_parse_error(&e.to_string()),
781 })?,
782 _ => {
783 return Err(CliError::UnknownExtension {
784 path: path.to_path_buf(),
785 });
786 }
787 };
788 Self::finish(cfg, path)
789 }
790
791 pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
799 let synthetic = Path::new("<submitted>");
800 let cfg: PipelineConfig =
801 serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
802 path: synthetic.to_path_buf(),
803 message: friendly_parse_error(&e.to_string()),
804 })?;
805 Self::finish(cfg, synthetic)
806 }
807
808 fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
810 if cfg.version != 1 {
811 return Err(CliError::ParseConfig {
812 path: path.to_path_buf(),
813 message: format!(
814 "unsupported pipeline version {}, only version 1 is recognised",
815 cfg.version
816 ),
817 });
818 }
819 crate::interpolate::resolve_config_refs(&mut cfg)?;
820 if let Some(obs) = cfg.observability.as_ref()
821 && let Some(otel) = obs.otel.as_ref()
822 {
823 otel.to_core().map_err(CliError::Config)?;
824 }
825 Ok(cfg)
826 }
827}
828
829fn friendly_parse_error(raw: &str) -> String {
832 let lower = raw.to_ascii_lowercase();
833 if lower.contains("missing field `pipeline`") {
834 return format!(
835 "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
836 );
837 }
838 if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
839 return format!(
840 "{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."
841 );
842 }
843 raw.to_owned()
844}
845
846pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
849 let synthetic = PathBuf::from(format!("pipeline.{ext}"));
850 PipelineConfig::from_text(text, &synthetic)
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856 use serde_json::json;
857
858 #[test]
859 fn parses_minimal_pipeline_yaml() {
860 let yaml = r#"
861version: 1
862pipeline:
863 source:
864 type: rest
865 config:
866 base_url: https://api.example.com
867 sink:
868 type: jsonl
869 config:
870 path: ./out.jsonl
871"#;
872 let cfg = parse_with_extension(yaml, "yaml").unwrap();
873 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
874 assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
875 assert!(cfg.matrix.is_empty());
876 assert!(cfg.execution.is_none());
877 assert!(cfg.pipeline.transforms.is_empty());
878 assert!(cfg.pipeline.state.is_none());
879 }
880
881 #[test]
882 fn parses_replication_block() {
883 let yaml = r#"
884version: 1
885pipeline:
886 source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
887 sink: { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
888 state: { type: file, config: { path: ./st } }
889replication:
890 mode: snapshot_then_cdc
891 snapshot:
892 source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
893"#;
894 let cfg = parse_with_extension(yaml, "yaml").unwrap();
895 let r = cfg.replication.expect("replication parsed");
896 assert_eq!(r.snapshot.source.kind, "postgres");
897 }
898
899 #[test]
900 fn pipeline_spec_parses_schema_block() {
901 let yaml = r#"
902version: 1
903pipeline:
904 source:
905 type: rest
906 config:
907 base_url: https://api.example.com
908 sink:
909 type: jsonl
910 config:
911 path: ./out.jsonl
912 schema:
913 on_drift: evolve
914 allow_type_widening: false
915"#;
916 let cfg = parse_with_extension(yaml, "yaml").unwrap();
917 let schema = cfg.pipeline.schema.expect("schema block parsed");
918 assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
919 assert!(!schema.allow_type_widening);
920 }
921
922 #[test]
923 fn parses_minimal_json() {
924 let raw = r#"{
925 "version": 1,
926 "pipeline": {
927 "source": {"type": "rest", "config": {}},
928 "sink": {"type": "jsonl", "config": {"path": "./out.jsonl"}}
929 }
930 }"#;
931 let cfg = parse_with_extension(raw, "json").unwrap();
932 assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
933 }
934
935 #[test]
936 fn parses_matrix_rows_with_partial_overrides() {
937 let yaml = r#"
938version: 1
939pipeline:
940 source: { type: rest, config: { base_url: https://api.example.com } }
941 sink: { type: jsonl, config: { path: ./out.jsonl } }
942matrix:
943 - id: users
944 source: { config: { path: /v1/users } }
945 sink: { config: { path: ./users.jsonl } }
946 - id: posts
947 parent: users
948 parent_key: user_id
949 source: { config: { path: "/v1/users/${users.id}/posts" } }
950"#;
951 let cfg = parse_with_extension(yaml, "yaml").unwrap();
952 assert_eq!(cfg.matrix.len(), 2);
953 assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
954 assert!(cfg.matrix[0].parent.is_none());
955 let users_src = cfg.matrix[0].source.as_ref().unwrap();
956 assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
957
958 assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
959 assert_eq!(cfg.matrix[1].parent_key, "user_id");
960 }
961
962 #[test]
963 fn parent_key_defaults_to_id() {
964 let yaml = r#"
965version: 1
966pipeline:
967 source: { type: rest, config: {} }
968 sink: { type: jsonl, config: { path: ./o.jsonl } }
969matrix:
970 - { id: users }
971 - { id: posts, parent: users }
972"#;
973 let cfg = parse_with_extension(yaml, "yaml").unwrap();
974 assert_eq!(cfg.matrix[1].parent_key, "id");
975 }
976
977 #[test]
978 fn parses_execution_block() {
979 let yaml = r#"
980version: 1
981pipeline:
982 source: { type: rest, config: {} }
983 sink: { type: jsonl, config: { path: ./o.jsonl } }
984execution:
985 max_concurrent: 8
986 on_error: stop
987"#;
988 let cfg = parse_with_extension(yaml, "yaml").unwrap();
989 let exec = cfg.execution.unwrap();
990 assert_eq!(exec.max_concurrent, Some(8));
991 assert_eq!(exec.on_error, OnError::Stop);
992 }
993
994 #[test]
995 fn on_error_defaults_to_continue() {
996 let yaml = r#"
997version: 1
998pipeline:
999 source: { type: rest, config: {} }
1000 sink: { type: jsonl, config: { path: ./o.jsonl } }
1001execution: { max_concurrent: 2 }
1002"#;
1003 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1004 assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1005 }
1006
1007 #[test]
1008 fn rejects_old_top_level_source_sink_with_hint() {
1009 let yaml = r#"
1011version: 1
1012source: { type: rest, config: {} }
1013sink: { type: jsonl, config: { path: ./o.jsonl } }
1014"#;
1015 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1016 let msg = err.to_string();
1017 assert!(
1018 msg.contains("pipeline"),
1019 "expected a hint about wrapping in `pipeline:`, got: {msg}"
1020 );
1021 }
1022
1023 #[test]
1024 fn rejects_unknown_extension() {
1025 let text = "version: 1\n";
1026 let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1027 assert!(matches!(err, CliError::UnknownExtension { .. }));
1028 }
1029
1030 #[test]
1031 fn rejects_future_version() {
1032 let yaml = r#"
1033version: 99
1034pipeline:
1035 source: { type: rest, config: {} }
1036 sink: { type: jsonl, config: { path: ./x } }
1037"#;
1038 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1039 match err {
1040 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1041 other => panic!("expected ParseConfig, got {other:?}"),
1042 }
1043 }
1044
1045 #[test]
1046 fn transforms_and_state_round_trip() {
1047 let yaml = r#"
1048version: 1
1049pipeline:
1050 source:
1051 type: rest
1052 config: {}
1053 transforms:
1054 - type: snake_case
1055 - type: flatten
1056 config: { separator: "__" }
1057 sink:
1058 type: jsonl
1059 config: { path: "./out.jsonl" }
1060 state:
1061 type: file
1062 config: { path: "./.faucet-state" }
1063"#;
1064 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1065 assert_eq!(cfg.pipeline.transforms.len(), 2);
1066 assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1067 assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1068 assert_eq!(
1069 cfg.pipeline.transforms[1].config,
1070 json!({"separator": "__"})
1071 );
1072 let state = cfg.pipeline.state.unwrap();
1073 assert_eq!(state.kind, "file");
1074 }
1075
1076 #[test]
1077 fn from_path_interpolates_env_var() {
1078 unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1079 let dir = tempfile::tempdir().unwrap();
1080 let path = dir.path().join("pipeline.yaml");
1081 std::fs::write(
1082 &path,
1083 r#"
1084version: 1
1085pipeline:
1086 source:
1087 type: rest
1088 config:
1089 base_url: ${env:FAUCET_CFG_URL}
1090 sink:
1091 type: jsonl
1092 config:
1093 path: ./out.jsonl
1094"#,
1095 )
1096 .unwrap();
1097 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1098 assert_eq!(
1099 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1100 "https://x.example"
1101 );
1102 unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1103 }
1104
1105 #[test]
1106 fn observability_block_parses() {
1107 let y = r#"
1108version: 1
1109name: x
1110observability:
1111 prometheus:
1112 listen: "127.0.0.1:9464"
1113 buckets: [0.01, 0.1, 1.0]
1114 tracing:
1115 level: "info"
1116pipeline:
1117 source:
1118 type: rest
1119 config:
1120 base_url: "https://example.com"
1121 path: "/data"
1122 sink:
1123 type: jsonl
1124 config:
1125 path: "/tmp/faucet-test.jsonl"
1126"#;
1127 let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1128 let obs = cfg.observability.expect("observability block parsed");
1129 let p = obs.prometheus.expect("prometheus parsed");
1130 assert_eq!(p.listen, "127.0.0.1:9464");
1131 assert_eq!(p.buckets.unwrap().len(), 3);
1132 assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1133 }
1134
1135 #[test]
1136 fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1137 let dir = tempfile::tempdir().unwrap();
1140 let path = dir.path().join("pipeline.yaml");
1141 std::fs::write(
1142 &path,
1143 r#"
1144version: 1
1145pipeline:
1146 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1147 sink: { type: jsonl, config: { path: ./o.jsonl } }
1148"#,
1149 )
1150 .unwrap();
1151 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1152 assert_eq!(
1153 cfg.pipeline.source.as_ref().unwrap().config["path"],
1154 "/v1/users/${users.id}/posts"
1155 );
1156 }
1157
1158 #[cfg(feature = "schedule")]
1159 #[test]
1160 fn parses_schedule_block() {
1161 let yaml = r#"
1162version: 1
1163schedule:
1164 cron: "0 2 * * *"
1165 timezone: "America/Los_Angeles"
1166 overlap_policy: skip
1167 max_consecutive_failures: 5
1168pipeline:
1169 source: { type: rest, config: {} }
1170 sink: { type: jsonl, config: { path: ./o.jsonl } }
1171"#;
1172 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1173 let s = cfg.schedule.expect("schedule parsed");
1174 assert_eq!(s.cron, "0 2 * * *");
1175 assert_eq!(s.timezone, "America/Los_Angeles");
1176 assert_eq!(s.max_consecutive_failures, Some(5));
1177 }
1178
1179 #[test]
1180 fn execution_spec_parses_adaptive_block() {
1181 let yaml = r#"
1182version: 1
1183pipeline:
1184 source: { type: rest, config: { base_url: https://api.example.com } }
1185 sink: { type: jsonl, config: { path: ./out.jsonl } }
1186execution:
1187 adaptive_batch_size:
1188 enabled: true
1189 min: 200
1190 max: 4000
1191 target_latency_ms: 800
1192"#;
1193 let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1194 let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1195 assert!(ab.enabled);
1196 assert_eq!(ab.min, 200);
1197 assert_eq!(ab.target_latency_ms, Some(800));
1198 ab.validate().unwrap();
1199 }
1200
1201 #[cfg(feature = "quality")]
1202 #[test]
1203 fn parses_quality_block() {
1204 let yaml = r#"
1205version: 1
1206pipeline:
1207 source: { type: rest, config: { url: "https://x" } }
1208 quality:
1209 record:
1210 - { type: not_null, field: id, on_failure: abort }
1211 sink: { type: stdout, config: {} }
1212"#;
1213 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1214 let q = cfg.pipeline.quality.expect("quality parsed");
1215 assert_eq!(q.record.len(), 1);
1216 }
1217
1218 #[test]
1219 fn parses_dlq_block_with_defaults() {
1220 let yaml = r#"
1221version: 1
1222pipeline:
1223 source: { type: rest, config: {} }
1224 sink: { type: jsonl, config: { path: ./o.jsonl } }
1225 dlq:
1226 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1227"#;
1228 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1229 let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1230 assert_eq!(dlq.sink.kind, "jsonl");
1231 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1232 assert!(dlq.max_failures_per_page.is_none());
1233 assert!(dlq.max_failures_total.is_none());
1234 assert!(dlq.include_original_payload);
1235 }
1236
1237 #[test]
1238 fn parses_dlq_block_with_dlq_all_and_budgets() {
1239 let yaml = r#"
1240version: 1
1241pipeline:
1242 source: { type: rest, config: {} }
1243 sink: { type: jsonl, config: { path: ./o.jsonl } }
1244 dlq:
1245 sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1246 on_batch_error: dlq_all
1247 max_failures_per_page: 100
1248 max_failures_total: 10000
1249"#;
1250 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1251 let dlq = cfg.pipeline.dlq.unwrap();
1252 assert_eq!(dlq.sink.kind, "kafka");
1253 assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1254 assert_eq!(dlq.max_failures_per_page, Some(100));
1255 assert_eq!(dlq.max_failures_total, Some(10000));
1256 }
1257
1258 #[test]
1259 fn matrix_row_dlq_null_disables_inherited_dlq() {
1260 let yaml = r#"
1261version: 1
1262pipeline:
1263 source: { type: rest, config: {} }
1264 sink: { type: jsonl, config: { path: ./o.jsonl } }
1265 dlq:
1266 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1267matrix:
1268 - id: a
1269 - id: b
1270 dlq: null
1271"#;
1272 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1273 assert!(cfg.matrix[0].dlq.is_none());
1274 assert_eq!(cfg.matrix[1].dlq, Some(None));
1275 }
1276
1277 #[test]
1278 fn matrix_row_dlq_object_replaces_inherited_dlq() {
1279 let yaml = r#"
1280version: 1
1281pipeline:
1282 source: { type: rest, config: {} }
1283 sink: { type: jsonl, config: { path: ./o.jsonl } }
1284 dlq:
1285 sink: { type: jsonl, config: { path: ./base.jsonl } }
1286matrix:
1287 - id: a
1288 dlq:
1289 sink: { type: jsonl, config: { path: ./a.jsonl } }
1290 on_batch_error: dlq_all
1291"#;
1292 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1293 let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1294 assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1295 let sink_path = row_dlq.sink.config.get("path").unwrap();
1296 assert_eq!(sink_path, "./a.jsonl");
1297 }
1298
1299 #[test]
1300 fn parses_named_sources_and_sinks() {
1301 let yaml = r#"
1302version: 1
1303pipeline:
1304 sources:
1305 users_api:
1306 type: rest
1307 config: { base_url: https://api.example.com }
1308 posts_api:
1309 type: rest
1310 config: { base_url: https://api.example.com }
1311 sinks:
1312 warehouse:
1313 type: postgres
1314 config: { connection_url: "postgres://x" }
1315"#;
1316 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1317 assert!(cfg.pipeline.source.is_none());
1318 assert!(cfg.pipeline.sink.is_none());
1319 assert_eq!(cfg.pipeline.sources.len(), 2);
1320 assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1321 assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1322 }
1323
1324 #[test]
1325 fn legacy_singular_source_still_parses() {
1326 let yaml = r#"
1327version: 1
1328pipeline:
1329 source: { type: rest, config: {} }
1330 sink: { type: jsonl, config: { path: ./o.jsonl } }
1331"#;
1332 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1333 assert!(cfg.pipeline.source.is_some());
1334 assert!(cfg.pipeline.sink.is_some());
1335 assert!(cfg.pipeline.sources.is_empty());
1336 assert!(cfg.pipeline.sinks.is_empty());
1337 }
1338
1339 #[test]
1340 fn parses_matrix_row_with_ref_field() {
1341 let yaml = r#"
1342version: 1
1343pipeline:
1344 source: { type: rest, config: {} }
1345 sink: { type: jsonl, config: { path: ./o.jsonl } }
1346matrix:
1347 - id: load_users
1348 source:
1349 ref: users_api
1350 config: { path: /v1/users }
1351"#;
1352 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1353 let src = cfg.matrix[0].source.as_ref().unwrap();
1354 assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1355 assert_eq!(src.kind, None);
1356 assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1357 }
1358
1359 #[test]
1360 fn parses_top_level_vars_block() {
1361 let yaml = r#"
1362version: 1
1363vars:
1364 api_base: https://api.example.com
1365 api_token_env: API_TOKEN
1366pipeline:
1367 source: { type: rest, config: {} }
1368 sink: { type: jsonl, config: { path: ./o.jsonl } }
1369"#;
1370 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1371 let vars = cfg.vars.as_ref().unwrap();
1372 assert_eq!(vars["api_base"], "https://api.example.com");
1373 assert_eq!(vars["api_token_env"], "API_TOKEN");
1374 }
1375
1376 #[test]
1377 fn vars_block_is_optional() {
1378 let yaml = r#"
1379version: 1
1380pipeline:
1381 source: { type: rest, config: {} }
1382 sink: { type: jsonl, config: { path: ./o.jsonl } }
1383"#;
1384 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1385 assert!(cfg.vars.is_none());
1386 }
1387
1388 #[test]
1389 fn from_path_resolves_vars_at_load() {
1390 let dir = tempfile::tempdir().unwrap();
1391 let path = dir.path().join("pipeline.yaml");
1392 std::fs::write(
1393 &path,
1394 r#"
1395version: 1
1396vars:
1397 base: https://api.example.com
1398pipeline:
1399 source: { type: rest, config: { url: "${vars.base}/v1" } }
1400 sink: { type: jsonl, config: { path: ./o.jsonl } }
1401"#,
1402 )
1403 .unwrap();
1404 let cfg = PipelineConfig::from_path(&path, None).unwrap();
1405 assert_eq!(
1406 cfg.pipeline.source.as_ref().unwrap().config["url"],
1407 "https://api.example.com/v1"
1408 );
1409 }
1410
1411 #[test]
1412 fn sync_from_path_errors_on_secret_directive() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let path = dir.path().join("p.yaml");
1415 std::fs::write(
1416 &path,
1417 r#"
1418version: 1
1419pipeline:
1420 source: { type: rest, config: { url: "${vault:secret/x}" } }
1421 sink: { type: jsonl, config: { path: ./o.jsonl } }
1422"#,
1423 )
1424 .unwrap();
1425 match PipelineConfig::from_path(&path, None).unwrap_err() {
1426 CliError::SecretsRequireAsyncLoad => {}
1427 other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1428 }
1429 }
1430
1431 #[test]
1432 fn from_value_accepts_v1_and_resolves_refs() {
1433 let v = serde_json::json!({
1434 "version": 1,
1435 "vars": { "out": "resolved.jsonl" },
1436 "pipeline": {
1437 "source": { "type": "csv", "config": { "path": "x.csv" } },
1438 "sink": { "type": "jsonl", "config": { "path": "${vars.out}" } }
1439 }
1440 });
1441 let cfg = PipelineConfig::from_value(v).unwrap();
1442 assert_eq!(cfg.version, 1);
1443 assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1445 }
1446
1447 #[test]
1448 fn from_value_rejects_non_v1() {
1449 let v = serde_json::json!({ "version": 99, "pipeline": {} });
1451 let err = PipelineConfig::from_value(v).unwrap_err();
1452 match err {
1453 CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1454 other => panic!("expected ParseConfig, got {other:?}"),
1455 }
1456 }
1457
1458 #[tokio::test]
1459 async fn async_from_path_loads_without_secrets() {
1460 let dir = tempfile::tempdir().unwrap();
1461 let path = dir.path().join("p.yaml");
1462 std::fs::write(
1463 &path,
1464 r#"
1465version: 1
1466pipeline:
1467 source: { type: rest, config: { base_url: https://x } }
1468 sink: { type: jsonl, config: { path: ./o.jsonl } }
1469"#,
1470 )
1471 .unwrap();
1472 let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1473 assert_eq!(cfg.version, 1);
1474 }
1475
1476 #[cfg(feature = "lineage")]
1477 #[test]
1478 fn parses_lineage_block() {
1479 let yaml = r#"
1480version: 1
1481lineage:
1482 namespace: prod
1483 transport: { type: file, config: { path: /tmp/ol.jsonl } }
1484pipeline:
1485 source: { type: rest, config: {} }
1486 sink: { type: jsonl, config: { path: ./o.jsonl } }
1487"#;
1488 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1489 let l = cfg.lineage.expect("lineage parsed");
1490 assert_eq!(l.namespace, "prod");
1491 }
1492
1493 #[test]
1494 fn from_path_resolves_extends_and_profile() {
1495 let dir = tempfile::tempdir().unwrap();
1496 std::fs::write(
1497 dir.path().join("base.yaml"),
1498 "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",
1499 )
1500 .unwrap();
1501 let app = dir.path().join("app.yaml");
1502 std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1503
1504 let cfg = PipelineConfig::from_path(&app, None).unwrap();
1506 assert_eq!(
1507 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1508 "base.jsonl"
1509 );
1510
1511 let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1513 assert_eq!(
1514 cfg.pipeline.sink.as_ref().unwrap().config["path"],
1515 "prod.jsonl"
1516 );
1517 }
1518
1519 #[test]
1520 fn from_value_rejects_extends_with_composition_hint() {
1521 let v = serde_json::json!({
1523 "version": 1,
1524 "extends": "base.yaml",
1525 "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1526 });
1527 let err = PipelineConfig::from_value(v).unwrap_err();
1528 let msg = err.to_string();
1529 assert!(
1530 msg.contains("composition"),
1531 "expected composition hint, got: {msg}"
1532 );
1533 }
1534
1535 #[test]
1536 fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1537 let yaml = r#"
1539version: 1
1540pipeline:
1541 source: { type: rest, config: {} }
1542 sink: { type: jsonl, config: { path: ./o.jsonl } }
1543"#;
1544 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1545 assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1546
1547 let yaml2 = r#"
1549version: 1
1550delivery: exactly_once
1551pipeline:
1552 source: { type: rest, config: {} }
1553 sink: { type: jsonl, config: { path: ./o.jsonl } }
1554"#;
1555 let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
1556 assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
1557
1558 let yaml3 = r#"
1560version: 1
1561delivery: at_least_once
1562pipeline:
1563 source: { type: rest, config: {} }
1564 sink: { type: jsonl, config: { path: ./o.jsonl } }
1565matrix:
1566 - id: a
1567 - id: b
1568 delivery: exactly_once
1569"#;
1570 let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
1571 assert_eq!(cfg3.matrix[0].delivery, None);
1572 assert_eq!(
1573 cfg3.matrix[1].delivery,
1574 Some(faucet_core::DeliveryMode::ExactlyOnce)
1575 );
1576 }
1577
1578 #[test]
1579 fn resilience_spec_parses_and_builds_policy() {
1580 let yaml = r#"
1581version: 1
1582pipeline:
1583 source: { type: rest, config: { base_url: "https://x" } }
1584 sink: { type: stdout, config: {} }
1585resilience:
1586 retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
1587 retry_on: [http_5xx, timeout]
1588 circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
1589 poison: { max_row_attempts: 2, action: dlq }
1590"#;
1591 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1592 let spec = cfg.resilience.unwrap();
1593 let policy = spec.to_policy().unwrap();
1594 assert_eq!(policy.retry.max_attempts, 4);
1595 assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
1596 assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
1597 }
1598
1599 #[test]
1600 fn resilience_rejects_zero_max_attempts() {
1601 let yaml = r#"
1602version: 1
1603pipeline:
1604 source: { type: rest, config: { base_url: "https://x" } }
1605 sink: { type: stdout, config: {} }
1606resilience: { retry: { max_attempts: 0 } }
1607"#;
1608 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1609 let err = cfg.resilience.unwrap().to_policy().unwrap_err();
1610 assert!(err.to_string().contains("max_attempts"));
1611 }
1612
1613 #[test]
1614 fn observability_parses_otel_block() {
1615 let yaml = r#"
1616version: 1
1617pipeline:
1618 source: { type: rest, config: { base_url: "http://x" } }
1619 sink: { type: stdout, config: {} }
1620observability:
1621 otel:
1622 endpoint: http://collector:4317
1623 protocol: grpc
1624 export: [traces, metrics]
1625"#;
1626 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1627 let otel = cfg.observability.unwrap().otel.unwrap();
1628 assert_eq!(otel.endpoint, "http://collector:4317");
1629 }
1630
1631 #[test]
1632 fn otel_validation_rejects_bad_ratio() {
1633 let yaml = r#"
1634version: 1
1635pipeline:
1636 source: { type: rest, config: { base_url: "http://x" } }
1637 sink: { type: stdout, config: {} }
1638observability:
1639 otel:
1640 sample_ratio: 9.0
1641"#;
1642 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1643 assert!(format!("{err}").contains("sample_ratio"));
1644 }
1645}