1mod chunk;
57mod component;
58mod error;
59mod interpolate;
60
61pub use component::ComponentConfig;
62pub use error::ConfigError;
63
64pub use serde_yaml::Value as YamlValue;
74
75use bytesize::ByteSize;
76use serde::Deserialize;
77use std::collections::BTreeMap;
78use std::net::SocketAddr;
79use std::path::Path;
80use std::time::Duration;
81
82#[derive(Debug, PartialEq, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct PipelineConfig {
88 pub pipeline: PipelineSection,
90 #[serde(default)]
92 pub checkpoint: CheckpointSection,
93 #[serde(default)]
95 pub backpressure: BackpressureSection,
96 #[serde(default)]
98 pub metrics: MetricsSection,
99 pub source: ComponentConfig,
101 #[serde(default)]
104 pub deserializer: Option<ComponentConfig>,
105 #[serde(default)]
109 pub sink: Option<ComponentConfig>,
110 #[serde(default)]
114 pub sinks: Option<BTreeMap<String, ComponentConfig>>,
115}
116
117#[derive(Debug, PartialEq, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct PipelineSection {
121 pub name: String,
123 #[serde(default)]
126 pub threads: Option<usize>,
127 #[serde(default = "defaults::io_threads")]
130 pub io_threads: usize,
131 #[serde(default)]
133 pub pinning: PinningMode,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
138#[serde(rename_all = "snake_case")]
139#[non_exhaustive]
140pub enum PinningMode {
141 #[default]
144 Off,
145 Compact,
147}
148
149#[derive(Debug, PartialEq, Deserialize)]
151#[serde(deny_unknown_fields, default)]
152pub struct CheckpointSection {
153 #[serde(with = "humantime_serde")]
155 pub interval: Duration,
156 pub max_pending_batches: usize,
159 #[serde(with = "humantime_serde")]
162 pub drain_timeout: Duration,
163 #[serde(with = "humantime_serde")]
171 pub stalled_fail_after: Duration,
172}
173
174impl Default for CheckpointSection {
175 fn default() -> Self {
176 CheckpointSection {
177 interval: Duration::from_secs(5),
178 max_pending_batches: 1024,
179 drain_timeout: Duration::from_secs(25),
180 stalled_fail_after: Duration::from_secs(120),
181 }
182 }
183}
184
185#[derive(Debug, PartialEq, Deserialize)]
187#[serde(deny_unknown_fields, default)]
188pub struct BackpressureSection {
189 pub max_inflight_bytes: ByteSize,
192 pub high_ratio: f64,
194 pub low_ratio: f64,
196 #[serde(with = "humantime_serde")]
199 pub min_pause: Duration,
200}
201
202impl Default for BackpressureSection {
203 fn default() -> Self {
204 BackpressureSection {
205 max_inflight_bytes: ByteSize::mib(256),
206 high_ratio: 0.8,
207 low_ratio: 0.5,
208 min_pause: Duration::from_millis(500),
209 }
210 }
211}
212
213#[derive(Debug, PartialEq, Deserialize)]
215#[serde(deny_unknown_fields, default)]
216pub struct MetricsSection {
217 pub exporter: MetricsExporter,
219 pub listen: SocketAddr,
221 pub per_partition_detail: bool,
224 pub e2e_basis: E2eBasis,
226}
227
228impl Default for MetricsSection {
229 fn default() -> Self {
230 MetricsSection {
231 exporter: MetricsExporter::Prometheus,
232 listen: SocketAddr::from(([0, 0, 0, 0], 9090)),
233 per_partition_detail: false,
234 e2e_basis: E2eBasis::Ingest,
235 }
236 }
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
241#[serde(rename_all = "snake_case")]
242#[non_exhaustive]
243pub enum MetricsExporter {
244 #[default]
246 Prometheus,
247 None,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
253#[serde(rename_all = "snake_case")]
254#[non_exhaustive]
255pub enum E2eBasis {
256 #[default]
258 Ingest,
259 Event,
262}
263
264mod defaults {
265 pub(super) fn io_threads() -> usize {
266 2
267 }
268}
269
270impl PipelineConfig {
271 #[expect(
277 clippy::should_implement_trait,
278 reason = "paired with from_path; no trait import required at call sites"
279 )]
280 pub fn from_str(text: &str) -> Result<Self, ConfigError> {
281 let interpolated = interpolate::interpolate(text)?;
282 Self::parse_interpolated(&interpolated)
283 }
284
285 pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
287 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
288 path: path.to_owned(),
289 source,
290 })?;
291 Self::from_str(&text)
292 }
293
294 fn parse_interpolated(text: &str) -> Result<Self, ConfigError> {
295 let de = serde_yaml::Deserializer::from_str(text);
296 let mut cfg: PipelineConfig =
297 serde_path_to_error::deserialize(de).map_err(|e| ConfigError::Parse {
298 path: e.path().to_string(),
299 source: e.into_inner(),
300 })?;
301 cfg.source.set_section("source");
302 if let Some(sink) = cfg.sink.as_mut() {
303 sink.set_section("sink");
304 }
305 if let Some(sinks) = cfg.sinks.as_mut() {
306 for sink in sinks.values_mut() {
307 sink.set_section("sink");
308 }
309 }
310 if let Some(deser) = cfg.deserializer.as_mut() {
311 deser.set_section("deserializer");
312 }
313 cfg.validate()?;
314 Ok(cfg)
315 }
316
317 pub fn validate(&self) -> Result<(), ConfigError> {
321 let fail = |msg: String| Err(ConfigError::Validation(msg));
322
323 if self.pipeline.name.trim().is_empty() {
324 return fail("pipeline.name must not be empty".into());
325 }
326 if self.pipeline.io_threads == 0 {
327 return fail("pipeline.io_threads must be at least 1".into());
328 }
329 if self.pipeline.threads == Some(0) {
330 return fail("pipeline.threads must be at least 1 when set".into());
331 }
332 const MIN_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
338 if self.checkpoint.interval < MIN_COMMIT_INTERVAL {
339 return fail(format!(
340 "checkpoint.interval must be at least 100ms (got {:?}): the commit \
341 loop fires every interval, so sub-100ms intervals hammer the \
342 source's offset store without improving durability",
343 self.checkpoint.interval
344 ));
345 }
346 if self.checkpoint.max_pending_batches == 0 {
347 return fail("checkpoint.max_pending_batches must be at least 1".into());
348 }
349 if self.checkpoint.drain_timeout.is_zero() {
350 return fail("checkpoint.drain_timeout must be greater than zero".into());
351 }
352 if self.checkpoint.stalled_fail_after.is_zero() {
353 return fail("checkpoint.stalled_fail_after must be greater than zero".into());
354 }
355 if self.backpressure.max_inflight_bytes.as_u64() == 0 {
356 return fail("backpressure.max_inflight_bytes must be greater than zero".into());
357 }
358 let (low, high) = (self.backpressure.low_ratio, self.backpressure.high_ratio);
359 if !(low > 0.0 && low < high && high <= 1.0) {
360 return fail(format!(
361 "backpressure ratios must satisfy 0 < low_ratio < high_ratio <= 1 \
362 (got low_ratio={low}, high_ratio={high})"
363 ));
364 }
365 match (&self.sink, &self.sinks) {
366 (Some(_), Some(_)) => {
367 return fail("set exactly one of `sink:` or `sinks:`, not both".into());
368 }
369 (None, None) => {
370 return fail("a `sink:` or `sinks:` section is required".into());
371 }
372 (None, Some(map)) if map.is_empty() => {
373 return fail("`sinks:` must declare at least one sink".into());
374 }
375 _ => {}
376 }
377 if let Some(sinks) = &self.sinks {
378 for name in sinks.keys() {
379 if name.is_empty() {
380 return fail("`sinks:` names must be non-empty".into());
381 }
382 if name == "sink" {
386 return fail(
387 "the sink name \"sink\" is reserved (it is the default \
388 sink's metric label); rename the `sinks:` entry"
389 .into(),
390 );
391 }
392 }
393 }
394 self.reject_stray_chunk()?;
395 if let Some(sink) = &self.sink {
399 sink.resolved_chunk()?;
400 }
401 if let Some(sinks) = &self.sinks {
402 for (name, sink) in sinks {
403 sink.resolved_chunk()
404 .map_err(|e| name_sinks_entry_error(name, e))?;
405 }
406 }
407 Ok(())
408 }
409
410 pub(crate) fn reject_stray_chunk(&self) -> Result<(), ConfigError> {
418 if self.source.resolved_chunk()?.is_some() {
419 return Err(ConfigError::Validation(
420 "`chunk:` is only valid on a sink section, not `source`".into(),
421 ));
422 }
423 if let Some(deser) = &self.deserializer
424 && deser.resolved_chunk()?.is_some()
425 {
426 return Err(ConfigError::Validation(
427 "`chunk:` is only valid on a sink section, not `deserializer`".into(),
428 ));
429 }
430 Ok(())
431 }
432
433 pub fn sink_config(&self, name: &str) -> Result<&ComponentConfig, ConfigError> {
441 if let Some(sinks) = &self.sinks {
442 sinks.get(name).ok_or_else(|| {
443 let known: Vec<&str> = sinks.keys().map(String::as_str).collect();
444 ConfigError::Validation(format!("no sink named {name:?} (configured: {known:?})"))
445 })
446 } else if name == "default" {
447 self.sink
448 .as_ref()
449 .ok_or_else(|| ConfigError::Validation("no sink configured".into()))
450 } else {
451 Err(ConfigError::Validation(format!(
452 "no sink named {name:?}: this pipeline configures a single `sink:` \
453 (address it as \"default\")"
454 )))
455 }
456 }
457
458 #[must_use]
461 pub fn sink_names(&self) -> Vec<String> {
462 match &self.sinks {
463 Some(sinks) => sinks.keys().cloned().collect(),
464 None => vec!["default".to_string()],
465 }
466 }
467}
468
469fn name_sinks_entry_error(name: &str, e: ConfigError) -> ConfigError {
473 match e {
474 ConfigError::Validation(m) => ConfigError::Validation(format!("sinks.{name}: {m}")),
475 ConfigError::Component { context, message } => ConfigError::Component {
476 context: match context.strip_prefix("sink.") {
477 Some(rest) => format!("sinks.{name}.{rest}"),
478 None => format!("sinks.{name}.{context}"),
479 },
480 message,
481 },
482 other => other,
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 const MINIMAL: &str = r#"
491pipeline: { name: demo }
492source: { memory: {} }
493sink: { memory: {} }
494"#;
495
496 #[test]
497 fn minimal_config_applies_documented_defaults() {
498 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
499 assert_eq!(cfg.pipeline.name, "demo");
500 assert_eq!(cfg.pipeline.threads, None);
501 assert_eq!(cfg.pipeline.io_threads, 2);
502 assert_eq!(cfg.pipeline.pinning, PinningMode::Off);
503 assert_eq!(cfg.checkpoint.interval, Duration::from_secs(5));
504 assert_eq!(cfg.checkpoint.max_pending_batches, 1024);
505 assert_eq!(cfg.checkpoint.drain_timeout, Duration::from_secs(25));
506 assert_eq!(cfg.checkpoint.stalled_fail_after, Duration::from_secs(120));
507 assert_eq!(cfg.backpressure.max_inflight_bytes, ByteSize::mib(256));
508 assert_eq!(cfg.backpressure.high_ratio, 0.8);
509 assert_eq!(cfg.backpressure.low_ratio, 0.5);
510 assert_eq!(cfg.backpressure.min_pause, Duration::from_millis(500));
511 assert_eq!(cfg.metrics.exporter, MetricsExporter::Prometheus);
512 assert_eq!(cfg.metrics.listen, SocketAddr::from(([0, 0, 0, 0], 9090)));
513 assert!(!cfg.metrics.per_partition_detail);
514 assert_eq!(cfg.metrics.e2e_basis, E2eBasis::Ingest);
515 assert!(cfg.deserializer.is_none());
516 }
517
518 #[test]
519 fn sink_chunk_block_parses_and_resolves() {
520 let yaml = r#"
521pipeline: { name: demo }
522source: { memory: {} }
523sink:
524 memory:
525 chunk: { target_bytes: 512KiB, encode_policy: fail }
526"#;
527 let cfg = PipelineConfig::from_str(yaml).unwrap();
528 let chunk = cfg
529 .sink_config("default")
530 .unwrap()
531 .resolved_chunk()
532 .unwrap()
533 .expect("chunk present");
534 assert_eq!(chunk.target_bytes, 512 * 1024);
535 assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
536 }
537
538 #[test]
539 fn chunk_on_a_source_section_is_rejected() {
540 let yaml = r#"
541pipeline: { name: demo }
542source:
543 memory:
544 chunk: { target_bytes: 64KiB }
545sink: { memory: {} }
546"#;
547 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
548 assert!(err.contains("chunk"), "{err}");
549 assert!(err.contains("source"), "{err}");
550 }
551
552 #[test]
553 fn zero_target_bytes_in_yaml_is_rejected_at_load() {
554 let yaml = r#"
555pipeline: { name: demo }
556source: { memory: {} }
557sink:
558 memory:
559 chunk: { target_bytes: 0B }
560"#;
561 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
564 assert!(err.contains("chunk.target_bytes"), "{err}");
565 }
566
567 #[test]
568 fn malformed_chunk_on_a_sinks_entry_is_rejected_at_load_naming_the_entry() {
569 let yaml = r#"
573pipeline: { name: demo }
574source: { memory: {} }
575sinks:
576 hot: { memory: {} }
577 cold:
578 memory:
579 chunk: { encode_policy: retry }
580"#;
581 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
582 assert!(err.contains("sinks.cold"), "{err}");
583 let yaml = r#"
584pipeline: { name: demo }
585source: { memory: {} }
586sinks:
587 cold:
588 memory:
589 chunk: { target_bytes: 0B }
590"#;
591 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
592 assert!(err.contains("sinks.cold"), "{err}");
593 assert!(err.contains("chunk.target_bytes"), "{err}");
594 }
595
596 #[test]
597 fn full_design_doc_example_parses() {
598 let yaml = r#"
607pipeline: { name: orders, threads: 4, io_threads: 2 }
608checkpoint: { interval: 5s, max_pending_batches: 1024 }
609backpressure: { max_inflight_bytes: 256MiB }
610source:
611 kafka:
612 brokers: ${KAFKA_BROKERS:-localhost:9092}
613 topic: orders
614 group_id: orders-etl
615 rdkafka: { fetch.message.max.bytes: "1048576" }
616deserializer:
617 avro:
618 mode: confluent
619 registry:
620 url: "${SCHEMA_REGISTRY_URL:-http://sr:8081}"
621sink:
622 clickhouse:
623 table: orders_local
624 columns: [id, amount, ts]
625 shards:
626 - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
627 - { replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"] }
628 batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
629 inflight: { max_per_shard: 2 }
630 retry: { initial: 100ms, max: 10s, multiplier: 2.0 }
631metrics: { exporter: prometheus, listen: 0.0.0.0:9090 }
632"#;
633 let cfg = PipelineConfig::from_str(yaml).unwrap();
634 assert_eq!(cfg.pipeline.threads, Some(4));
635 assert_eq!(cfg.source.type_tag(), "kafka");
636 assert_eq!(cfg.deserializer.as_ref().unwrap().type_tag(), "avro");
637 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "clickhouse");
638
639 #[derive(Debug, serde::Deserialize)]
642 struct KafkaProbe {
643 brokers: String,
644 group_id: String,
645 #[serde(flatten)]
646 _rest: serde_yaml::Value,
647 }
648 let kafka: KafkaProbe = cfg.source.deserialize_into().unwrap();
649 assert_eq!(kafka.brokers, "localhost:9092");
650 assert_eq!(kafka.group_id, "orders-etl");
651
652 #[derive(Debug, serde::Deserialize)]
655 struct AvroProbe {
656 registry: RegistryProbe,
657 }
658 #[derive(Debug, serde::Deserialize)]
659 struct RegistryProbe {
660 url: String,
661 }
662 let avro: AvroProbe = cfg
663 .deserializer
664 .as_ref()
665 .unwrap()
666 .deserialize_into()
667 .unwrap();
668 assert_eq!(avro.registry.url, "http://sr:8081");
669
670 #[derive(Debug, serde::Deserialize)]
671 struct ChProbe {
672 columns: Vec<String>,
673 }
674 let ch: ChProbe = cfg
675 .sink_config("default")
676 .unwrap()
677 .deserialize_into()
678 .unwrap();
679 assert_eq!(ch.columns, ["id", "amount", "ts"]);
680 }
681
682 #[test]
683 fn single_sink_resolves_as_default() {
684 let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
685 assert_eq!(cfg.sink_names(), vec!["default".to_string()]);
686 assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "memory");
687 assert!(cfg.sink_config("other").is_err());
688 }
689
690 #[test]
691 fn sinks_map_parses_and_resolves_by_name() {
692 let yaml = r#"
693pipeline: { name: demo }
694source: { memory: {} }
695sinks:
696 type_a: { memory: {} }
697 type_b: { memory: {} }
698"#;
699 let cfg = PipelineConfig::from_str(yaml).unwrap();
700 assert_eq!(
701 cfg.sink_names(),
702 vec!["type_a".to_string(), "type_b".to_string()]
703 );
704 assert_eq!(cfg.sink_config("type_a").unwrap().type_tag(), "memory");
705 assert_eq!(cfg.sink_config("type_b").unwrap().type_tag(), "memory");
706 assert!(cfg.sink_config("default").is_err());
708 }
709
710 #[test]
711 fn sink_and_sinks_are_mutually_exclusive() {
712 let both = r#"
713pipeline: { name: demo }
714source: { memory: {} }
715sink: { memory: {} }
716sinks:
717 a: { memory: {} }
718"#;
719 assert!(matches!(
720 PipelineConfig::from_str(both),
721 Err(ConfigError::Validation(_))
722 ));
723
724 let neither = r#"
725pipeline: { name: demo }
726source: { memory: {} }
727"#;
728 assert!(matches!(
729 PipelineConfig::from_str(neither),
730 Err(ConfigError::Validation(_))
731 ));
732
733 let empty = r#"
734pipeline: { name: demo }
735source: { memory: {} }
736sinks: {}
737"#;
738 assert!(matches!(
739 PipelineConfig::from_str(empty),
740 Err(ConfigError::Validation(_))
741 ));
742 }
743
744 #[test]
745 fn reserved_sink_names_are_rejected() {
746 let reserved = r#"
749pipeline: { name: demo }
750source: { memory: {} }
751sinks:
752 sink: { memory: {} }
753"#;
754 assert!(matches!(
755 PipelineConfig::from_str(reserved),
756 Err(ConfigError::Validation(msg)) if msg.contains("reserved")
757 ));
758
759 let empty_name = r#"
760pipeline: { name: demo }
761source: { memory: {} }
762sinks:
763 "": { memory: {} }
764"#;
765 assert!(matches!(
766 PipelineConfig::from_str(empty_name),
767 Err(ConfigError::Validation(msg)) if msg.contains("non-empty")
768 ));
769 }
770
771 #[test]
772 fn unknown_fields_are_rejected_at_every_typed_level() {
773 for (yaml, field) in [
774 (
775 "pipeline: { name: x, bogus: 1 }\nsource: { m: {} }\nsink: { m: {} }",
776 "bogus",
777 ),
778 (
779 "pipeline: { name: x }\ncheckpoint: { intervall: 5s }\nsource: { m: {} }\nsink: { m: {} }",
780 "intervall",
781 ),
782 (
783 "pipeline: { name: x }\nmetrics: { port: 9 }\nsource: { m: {} }\nsink: { m: {} }",
784 "port",
785 ),
786 (
787 "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: {} }\nsinks: {}",
788 "sinks",
789 ),
790 ] {
791 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
792 assert!(err.contains(field), "expected `{field}` in error: {err}");
793 }
794 }
795
796 #[test]
797 fn parse_errors_carry_the_yaml_path() {
798 let yaml = "pipeline: { name: x, io_threads: many }\nsource: { m: {} }\nsink: { m: {} }";
799 let err = PipelineConfig::from_str(yaml).unwrap_err();
800 let text = err.to_string();
801 assert!(text.contains("pipeline.io_threads"), "{text}");
802 }
803
804 #[test]
805 fn validation_rules() {
806 let cases = [
807 (
808 "pipeline: { name: ' ' }\nsource: { m: {} }\nsink: { m: {} }",
809 "pipeline.name",
810 ),
811 (
812 "pipeline: { name: x, io_threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
813 "io_threads",
814 ),
815 (
816 "pipeline: { name: x, threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
817 "threads",
818 ),
819 (
820 "pipeline: { name: x }\ncheckpoint: { interval: 0s }\nsource: { m: {} }\nsink: { m: {} }",
821 "interval",
822 ),
823 (
824 "pipeline: { name: x }\ncheckpoint: { interval: 50ms }\nsource: { m: {} }\nsink: { m: {} }",
825 "at least 100ms",
826 ),
827 (
828 "pipeline: { name: x }\ncheckpoint: { max_pending_batches: 0 }\nsource: { m: {} }\nsink: { m: {} }",
829 "max_pending_batches",
830 ),
831 (
832 "pipeline: { name: x }\ncheckpoint: { drain_timeout: 0s }\nsource: { m: {} }\nsink: { m: {} }",
833 "drain_timeout",
834 ),
835 (
836 "pipeline: { name: x }\ncheckpoint: { stalled_fail_after: 0s }\nsource: { m: {} }\nsink: { m: {} }",
837 "stalled_fail_after",
838 ),
839 (
840 "pipeline: { name: x }\nbackpressure: { max_inflight_bytes: 0 }\nsource: { m: {} }\nsink: { m: {} }",
841 "max_inflight_bytes",
842 ),
843 (
844 "pipeline: { name: x }\nbackpressure: { low_ratio: 0.9, high_ratio: 0.8 }\nsource: { m: {} }\nsink: { m: {} }",
845 "low_ratio",
846 ),
847 (
848 "pipeline: { name: x }\nbackpressure: { high_ratio: 1.5 }\nsource: { m: {} }\nsink: { m: {} }",
849 "high_ratio",
850 ),
851 ];
852 for (yaml, needle) in cases {
853 let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
854 assert!(err.contains(needle), "expected `{needle}` in: {err}");
855 }
856 }
857
858 #[test]
859 fn missing_required_sections_error_clearly() {
860 let err = PipelineConfig::from_str("pipeline: { name: x }\nsink: { m: {} }")
861 .unwrap_err()
862 .to_string();
863 assert!(err.contains("source"), "{err}");
864 let err = PipelineConfig::from_str("source: { m: {} }\nsink: { m: {} }")
865 .unwrap_err()
866 .to_string();
867 assert!(err.contains("pipeline"), "{err}");
868 }
869
870 #[test]
871 fn interpolation_failures_surface_with_position() {
872 let err = PipelineConfig::from_str("pipeline:\n name: ${UNSET_VAR_FOR_TEST}\n")
873 .unwrap_err()
874 .to_string();
875 assert!(err.contains("UNSET_VAR_FOR_TEST"), "{err}");
876 assert!(err.contains("line 2"), "{err}");
877 }
878
879 #[test]
880 fn from_path_reads_interpolates_and_reports_io_errors() {
881 use std::io::Write as _;
882 let mut file = tempfile::NamedTempFile::new().unwrap();
883 write!(
884 file,
885 "pipeline: {{ name: ${{FILE_TEST_NAME:-from-file}} }}\nsource: {{ m: {{}} }}\nsink: {{ m: {{}} }}\n"
886 )
887 .unwrap();
888 let cfg = PipelineConfig::from_path(file.path()).unwrap();
889 assert_eq!(cfg.pipeline.name, "from-file");
890
891 let err = PipelineConfig::from_path(Path::new("/nonexistent/spate.yaml")).unwrap_err();
892 assert!(matches!(err, ConfigError::Io { .. }));
893 assert!(err.to_string().contains("/nonexistent/spate.yaml"));
894 }
895
896 #[test]
897 fn equal_inputs_parse_to_equal_configs() {
898 let a = PipelineConfig::from_str(MINIMAL).unwrap();
899 let b = PipelineConfig::from_str(MINIMAL).unwrap();
900 assert_eq!(a, b);
901 let c = PipelineConfig::from_str(
902 "pipeline: { name: other }\nsource: { m: {} }\nsink: { m: {} }",
903 )
904 .unwrap();
905 assert_ne!(a, c);
906 }
907}