Skip to main content

spate_core/config/
mod.rs

1//! Pipeline configuration: typed framework sections plus opaque
2//! per-component passthrough, loaded from YAML with `${VAR:-default}`
3//! environment interpolation.
4//!
5//! The framework owns the typed sections (`admin`, `backpressure`,
6//! `checkpoint`, `metrics`, `pipeline`) and validates them strictly
7//! (`deny_unknown_fields` at every level). The `source`, `deserializer`,
8//! and `sink` sections are single-key mappings selecting a component type;
9//! their bodies are opaque [`ComponentConfig`]s handed to the component's
10//! factory, which deserializes its own typed config.
11//!
12//! ```yaml
13//! pipeline: { name: orders, threads: 4, io_threads: 2 }
14//! checkpoint: { interval: 5s, max_pending_batches: 1024 }
15//! backpressure: { max_inflight_bytes: 256MiB }
16//! source:
17//!   kafka:                                   # KafkaSourceConfig
18//!     brokers: ${KAFKA_BROKERS:-localhost:9092}
19//!     topic: orders
20//!     group_id: orders-etl                   # required (no default)
21//! deserializer:
22//!   avro:                                    # AvroSettings (confluent mode)
23//!     registry:
24//!       url: ${SCHEMA_REGISTRY_URL:?schema registry required}
25//! sink:
26//!   clickhouse:                              # ClickHouseSinkConfig
27//!     table: orders_local
28//!     columns: [id, amount, ts]              # required; order is the wire contract
29//!     shards:
30//!       - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
31//! admin: { listen: 0.0.0.0:9090 }           # /metrics, /healthz, /readyz
32//! metrics: { exporter: prometheus }
33//! ```
34//!
35//! Environment interpolation runs on the raw text before parsing. See
36//! `interpolate_with` for the exact semantics of `${VAR}`,
37//! `${VAR:-default}`, `${VAR:?message}`, and `$$`.
38//!
39//! Every framework section here is `#[non_exhaustive]`. Build a config with
40//! [`PipelineConfig::new`] or [`PipelineConfig::new_multi_sink`], a
41//! [`PipelineSection`] with [`PipelineSection::new`], and the optional
42//! sections with `default()`, then assign the fields you are setting; a key
43//! added later arrives as a new default and existing code keeps compiling.
44//!
45//! # Example
46//!
47//! ```
48//! use spate_core::config::PipelineConfig;
49//!
50//! let cfg = PipelineConfig::from_str(r#"
51//! pipeline: { name: demo }
52//! source: { memory: {} }
53//! sink: { memory: {} }
54//! "#).unwrap();
55//!
56//! assert_eq!(cfg.pipeline.name, "demo");
57//! assert_eq!(cfg.pipeline.io_threads, 2);                    // default
58//! assert_eq!(cfg.checkpoint.max_pending_batches, 1024);      // default
59//! assert_eq!(cfg.source.type_tag(), "memory");
60//! ```
61
62mod chunk;
63mod component;
64mod error;
65mod interpolate;
66
67pub use component::ComponentConfig;
68pub use error::ConfigError;
69
70/// Re-export of `serde_yaml::Value`, the opaque body type carried by a
71/// [`ComponentConfig`]. `serde_yaml` is a 0.x dependency, so exposing its
72/// `Value` directly in `spate-core`'s public API would tie our semver to
73/// theirs; this alias is the documented exemption (mirroring the [`bytes`]
74/// and `AvroValue` re-export pattern, INV-6).
75/// A major bump of the YAML crate becomes a breaking change here,
76/// and only here.
77///
78/// [`bytes`]: crate::bytes
79pub use serde_yaml::Value as YamlValue;
80
81use bytesize::ByteSize;
82use serde::Deserialize;
83use std::collections::BTreeMap;
84use std::net::SocketAddr;
85use std::path::Path;
86use std::time::Duration;
87
88/// Root of a pipeline's configuration file.
89///
90/// One process runs one pipeline; one file configures one process.
91///
92/// Construct with [`PipelineConfig::new`], or
93/// [`PipelineConfig::new_multi_sink`] for a `sinks:` map, and set the optional
94/// fields. The struct is `#[non_exhaustive]` so new sections can be added
95/// without breaking callers.
96#[derive(Debug, PartialEq, Deserialize)]
97#[serde(deny_unknown_fields)]
98#[non_exhaustive]
99pub struct PipelineConfig {
100    /// Identity and thread budget.
101    pub pipeline: PipelineSection,
102    /// The admin server carrying `/metrics`, `/healthz` and `/readyz`.
103    #[serde(default)]
104    pub admin: AdminSection,
105    /// In-flight budget and pause/resume hysteresis.
106    #[serde(default)]
107    pub backpressure: BackpressureSection,
108    /// Watermark commit policy.
109    #[serde(default)]
110    pub checkpoint: CheckpointSection,
111    /// Exporter selection and observability knobs.
112    #[serde(default)]
113    pub metrics: MetricsSection,
114    /// The source component (opaque body).
115    pub source: ComponentConfig,
116    /// Optional deserializer component (opaque body). Sources that emit
117    /// ready-made records need none.
118    #[serde(default)]
119    pub deserializer: Option<ComponentConfig>,
120    /// Single sink component (opaque body), sugar for the common one-sink
121    /// case, addressed as `"default"`. Mutually exclusive with `sinks`.
122    /// Resolve via [`sink_config`](Self::sink_config).
123    #[serde(default)]
124    pub sink: Option<ComponentConfig>,
125    /// Named sinks for a multi-sink split: a `name -> component` map, each an
126    /// ordinary single-key component (`clickhouse: {...}`). Mutually exclusive
127    /// with `sink`. Resolve via [`sink_config`](Self::sink_config).
128    #[serde(default)]
129    pub sinks: Option<BTreeMap<String, ComponentConfig>>,
130}
131
132/// Identity and thread budget (`pipeline:`).
133///
134/// Construct with [`PipelineSection::new`] and set the optional fields. The
135/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
136/// callers.
137#[derive(Debug, PartialEq, Deserialize)]
138#[serde(deny_unknown_fields)]
139#[non_exhaustive]
140pub struct PipelineSection {
141    /// Pipeline name; the `pipeline` label on every metric.
142    pub name: String,
143    /// Pinned pipeline thread count. `None` derives from
144    /// `available_parallelism` minus the I/O reserve at startup.
145    #[serde(default)]
146    pub threads: Option<usize>,
147    /// Worker threads for the I/O runtime (sink workers, checkpointer,
148    /// admin server).
149    #[serde(default = "defaults::io_threads")]
150    pub io_threads: usize,
151    /// Core-pinning mode for pipeline threads.
152    #[serde(default)]
153    pub pinning: PinningMode,
154}
155
156impl PipelineSection {
157    /// A section named `name`. Every other field starts at its YAML default.
158    ///
159    /// ```
160    /// use spate_core::config::PipelineSection;
161    ///
162    /// let mut pipeline = PipelineSection::new("orders");
163    /// pipeline.io_threads = 4;
164    ///
165    /// assert_eq!(pipeline.name, "orders");
166    /// assert_eq!(pipeline.threads, None);
167    /// ```
168    #[must_use]
169    pub fn new(name: impl Into<String>) -> PipelineSection {
170        PipelineSection {
171            name: name.into(),
172            threads: None,
173            io_threads: defaults::io_threads(),
174            pinning: PinningMode::default(),
175        }
176    }
177}
178
179/// How pipeline threads are pinned to cores.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
181#[serde(rename_all = "snake_case")]
182#[non_exhaustive]
183pub enum PinningMode {
184    /// No pinning (default). Correct choice unless the pod has exclusive
185    /// cores (Kubernetes static CPU manager + Guaranteed QoS).
186    #[default]
187    Off,
188    /// Pin thread *i* to core *i*.
189    Compact,
190}
191
192/// Watermark commit policy (`checkpoint:`).
193///
194/// Construct with [`CheckpointSection::default`] and set the fields. The
195/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
196/// callers.
197#[derive(Debug, PartialEq, Deserialize)]
198#[serde(deny_unknown_fields, default)]
199#[non_exhaustive]
200pub struct CheckpointSection {
201    /// How often committable watermarks are flushed to the source.
202    #[serde(with = "humantime_serde")]
203    pub interval: Duration,
204    /// Hard per-partition ceiling on unacknowledged batches. A partition at
205    /// the ceiling has its lanes skipped at the poll boundary until
206    /// acknowledgments retire batches. No pause is involved, and other
207    /// partitions are unaffected. Bounds tracker memory and the replay a
208    /// stalled partition can accumulate.
209    pub max_pending_batches: usize,
210    /// Shutdown/rebalance drain budget. Must be comfortably below the pod's
211    /// `terminationGracePeriodSeconds`.
212    #[serde(with = "humantime_serde")]
213    pub drain_timeout: Duration,
214    /// A partition watermark stalled behind a failed batch for longer than
215    /// this fails the pipeline. Failed batches only stall watermarks
216    /// permanently (their data replays after restart), so this converts a
217    /// permanent sink failure (a dropped table, revoked credentials) into a
218    /// clean `Failed` exit and a restart instead of a process that runs on
219    /// forever, consuming the source but committing nothing for that
220    /// partition.
221    #[serde(with = "humantime_serde")]
222    pub stalled_fail_after: Duration,
223}
224
225impl Default for CheckpointSection {
226    fn default() -> Self {
227        CheckpointSection {
228            interval: Duration::from_secs(5),
229            max_pending_batches: 1024,
230            drain_timeout: Duration::from_secs(25),
231            stalled_fail_after: Duration::from_secs(120),
232        }
233    }
234}
235
236/// In-flight budget and hysteresis (`backpressure:`).
237///
238/// Construct with [`BackpressureSection::default`] and set the fields. The
239/// struct is `#[non_exhaustive]` so new knobs can be added without breaking
240/// callers.
241#[derive(Debug, PartialEq, Deserialize)]
242#[serde(deny_unknown_fields, default)]
243#[non_exhaustive]
244pub struct BackpressureSection {
245    /// Global cap on bytes admitted into the pipeline but not yet durably
246    /// written.
247    pub max_inflight_bytes: ByteSize,
248    /// Fraction of the budget at which sources are paused.
249    pub high_ratio: f64,
250    /// Fraction of the budget below which sources may resume.
251    pub low_ratio: f64,
252    /// Minimum pause duration before resuming (avoids flapping; pausing a
253    /// Kafka partition purges its prefetch, so resume is not free).
254    #[serde(with = "humantime_serde")]
255    pub min_pause: Duration,
256}
257
258impl Default for BackpressureSection {
259    fn default() -> Self {
260        BackpressureSection {
261            max_inflight_bytes: ByteSize::mib(256),
262            high_ratio: 0.8,
263            low_ratio: 0.5,
264            min_pause: Duration::from_millis(500),
265        }
266    }
267}
268
269/// The HTTP server carrying `/metrics`, `/healthz` and `/readyz` (`admin:`).
270///
271/// One server serves all three. The probes answer regardless of
272/// [`MetricsSection::exporter`]. `/metrics` is served only where this
273/// pipeline's own handle renders an exposition. `exporter: none` leaves it a
274/// 404, and so does a recorder another library installed first, which this
275/// pipeline records into but cannot render.
276///
277/// Construct with [`AdminSection::default`] and set the fields. The struct is
278/// `#[non_exhaustive]` so new knobs can be added without breaking callers.
279#[derive(Debug, PartialEq, Deserialize)]
280#[serde(deny_unknown_fields, default)]
281#[non_exhaustive]
282pub struct AdminSection {
283    /// Bind address, or `none` for no server at all.
284    ///
285    /// With no server the probes and the exposition are unreachable over
286    /// HTTP. The exposition is still readable in-process through
287    /// [`Pipeline::metrics`](crate::pipeline::Pipeline::metrics), which is
288    /// what an embedding program mounting it on its own server uses.
289    #[serde(deserialize_with = "listen_or_none")]
290    pub listen: Option<SocketAddr>,
291}
292
293impl Default for AdminSection {
294    fn default() -> Self {
295        AdminSection {
296            listen: Some(SocketAddr::from(([0, 0, 0, 0], 9090))),
297        }
298    }
299}
300
301/// Deserialize a bind address or the literal `none`.
302///
303/// Takes the value through a visitor rather than through `String` so that a
304/// YAML null (`~`, `null`, or a bare `listen:`) reports the two accepted
305/// spellings instead of a type mismatch against an intermediate this key does
306/// not otherwise have.
307fn listen_or_none<'de, D>(de: D) -> Result<Option<SocketAddr>, D::Error>
308where
309    D: serde::Deserializer<'de>,
310{
311    struct Visitor;
312
313    const EXPECTING: &str = r#"a socket address or "none""#;
314
315    impl serde::de::Visitor<'_> for Visitor {
316        type Value = Option<SocketAddr>;
317
318        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
319            f.write_str(EXPECTING)
320        }
321
322        fn visit_str<E: serde::de::Error>(self, text: &str) -> Result<Self::Value, E> {
323            if text == "none" {
324                return Ok(None);
325            }
326            text.parse()
327                .map(Some)
328                .map_err(|_| E::invalid_value(serde::de::Unexpected::Str(text), &EXPECTING))
329        }
330    }
331
332    de.deserialize_any(Visitor)
333}
334
335/// Exporter selection and observability knobs (`metrics:`).
336///
337/// Construct with [`MetricsSection::default`] and set the fields. The struct
338/// is `#[non_exhaustive]` so new knobs can be added without breaking callers.
339#[derive(Debug, PartialEq, Deserialize)]
340#[serde(deny_unknown_fields, default)]
341#[non_exhaustive]
342pub struct MetricsSection {
343    /// Which exporter to install.
344    pub exporter: MetricsExporter,
345    /// Emit per-partition gauge series (`partition` label). Off by default:
346    /// cardinality grows with the assignment.
347    pub per_partition_detail: bool,
348    /// Time basis for `spate_e2e_latency_seconds`.
349    pub e2e_basis: E2eBasis,
350}
351
352impl Default for MetricsSection {
353    fn default() -> Self {
354        MetricsSection {
355            exporter: MetricsExporter::Prometheus,
356            per_partition_detail: false,
357            e2e_basis: E2eBasis::Ingest,
358        }
359    }
360}
361
362/// Metrics exporter selection.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
364#[serde(rename_all = "snake_case")]
365#[non_exhaustive]
366pub enum MetricsExporter {
367    /// Prometheus scrape endpoint on the admin server (default).
368    #[default]
369    Prometheus,
370    /// No exporter (metrics recorded to a no-op recorder).
371    None,
372}
373
374/// Which timestamp anchors end-to-end latency.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
376#[serde(rename_all = "snake_case")]
377#[non_exhaustive]
378pub enum E2eBasis {
379    /// Framework ingest time, immune to producer clock skew (default).
380    #[default]
381    Ingest,
382    /// Record event time (e.g. Kafka message timestamp). Measures true
383    /// pipeline lag but is sensitive to upstream clocks.
384    Event,
385}
386
387mod defaults {
388    pub(super) fn io_threads() -> usize {
389        2
390    }
391}
392
393/// Tag a component with the section it sits in, so its error paths read the
394/// same whether the config was parsed or built in code.
395fn labelled(mut component: ComponentConfig, section: &'static str) -> ComponentConfig {
396    component.set_section(section);
397    component
398}
399
400impl PipelineConfig {
401    /// A config for one source and one sink, the `sink:` form. Every optional
402    /// section starts at its YAML default, and the sink is addressed as
403    /// `"default"` by [`sink_config`](Self::sink_config).
404    ///
405    /// ```
406    /// use spate_core::config::{ComponentConfig, PipelineConfig, PipelineSection, YamlValue};
407    /// use std::time::Duration;
408    ///
409    /// let mut pipeline = PipelineSection::new("orders");
410    /// pipeline.io_threads = 4;
411    ///
412    /// let mut cfg = PipelineConfig::new(
413    ///     pipeline,
414    ///     ComponentConfig::new("memory", YamlValue::Mapping(Default::default())),
415    ///     ComponentConfig::new("memory", YamlValue::Mapping(Default::default())),
416    /// );
417    /// cfg.checkpoint.interval = Duration::from_secs(10);
418    /// cfg.validate()?;
419    ///
420    /// assert_eq!(cfg.sink_config("default")?.type_tag(), "memory");
421    /// # Ok::<(), spate_core::config::ConfigError>(())
422    /// ```
423    #[must_use]
424    pub fn new(
425        pipeline: PipelineSection,
426        source: ComponentConfig,
427        sink: ComponentConfig,
428    ) -> PipelineConfig {
429        Self::assemble(pipeline, source, Some(labelled(sink, "sink")), None)
430    }
431
432    /// A config for one source and a map of named sinks, the `sinks:` form.
433    /// Every optional section starts at its YAML default, and each name
434    /// addresses its sink through [`sink_config`](Self::sink_config).
435    ///
436    /// The map is not checked here. [`validate`](Self::validate) rejects an
437    /// empty map, an empty name, and the reserved name `"sink"`.
438    ///
439    /// ```
440    /// use spate_core::config::{ComponentConfig, PipelineConfig, PipelineSection, YamlValue};
441    /// use std::collections::BTreeMap;
442    ///
443    /// let body = || YamlValue::Mapping(Default::default());
444    /// let sinks = BTreeMap::from([
445    ///     ("eu".to_owned(), ComponentConfig::new("memory", body())),
446    ///     ("us".to_owned(), ComponentConfig::new("memory", body())),
447    /// ]);
448    ///
449    /// let cfg = PipelineConfig::new_multi_sink(
450    ///     PipelineSection::new("orders"),
451    ///     ComponentConfig::new("memory", body()),
452    ///     sinks,
453    /// );
454    /// cfg.validate()?;
455    ///
456    /// assert_eq!(cfg.sink_names(), ["eu", "us"]);
457    /// # Ok::<(), spate_core::config::ConfigError>(())
458    /// ```
459    #[must_use]
460    pub fn new_multi_sink(
461        pipeline: PipelineSection,
462        source: ComponentConfig,
463        sinks: BTreeMap<String, ComponentConfig>,
464    ) -> PipelineConfig {
465        let sinks = sinks
466            .into_iter()
467            .map(|(name, sink)| (name, labelled(sink, "sink")))
468            .collect();
469        Self::assemble(pipeline, source, None, Some(sinks))
470    }
471
472    /// The same config with a deserializer component, tagged with its
473    /// section the way the constructors tag the source and the sink.
474    ///
475    /// ```
476    /// use spate_core::config::{ComponentConfig, PipelineConfig, PipelineSection, YamlValue};
477    ///
478    /// let body = || YamlValue::Mapping(Default::default());
479    /// let cfg = PipelineConfig::new(
480    ///     PipelineSection::new("orders"),
481    ///     ComponentConfig::new("memory", body()),
482    ///     ComponentConfig::new("memory", body()),
483    /// )
484    /// .with_deserializer(ComponentConfig::new("json", body()));
485    ///
486    /// assert_eq!(
487    ///     cfg.deserializer.as_ref().map(ComponentConfig::type_tag),
488    ///     Some("json")
489    /// );
490    /// ```
491    #[must_use]
492    pub fn with_deserializer(mut self, deserializer: ComponentConfig) -> PipelineConfig {
493        self.deserializer = Some(labelled(deserializer, "deserializer"));
494        self
495    }
496
497    /// The one place the field list lives, so a section added later reaches
498    /// both constructors. Callers pass exactly one of `sink`/`sinks`.
499    fn assemble(
500        pipeline: PipelineSection,
501        source: ComponentConfig,
502        sink: Option<ComponentConfig>,
503        sinks: Option<BTreeMap<String, ComponentConfig>>,
504    ) -> PipelineConfig {
505        PipelineConfig {
506            pipeline,
507            admin: AdminSection::default(),
508            backpressure: BackpressureSection::default(),
509            checkpoint: CheckpointSection::default(),
510            metrics: MetricsSection::default(),
511            source: labelled(source, "source"),
512            deserializer: None,
513            sink,
514            sinks,
515        }
516    }
517
518    /// Load from YAML text: interpolate `${VAR}` forms against the process
519    /// environment, parse, and validate.
520    // An inherent `from_str` (rather than `std::str::FromStr`) keeps the
521    // call site `PipelineConfig::from_str(text)?` working without a trait
522    // import, matching `from_path` beside it.
523    #[expect(
524        clippy::should_implement_trait,
525        reason = "paired with from_path; no trait import required at call sites"
526    )]
527    pub fn from_str(text: &str) -> Result<Self, ConfigError> {
528        let interpolated = interpolate::interpolate(text)?;
529        Self::parse_interpolated(&interpolated)
530    }
531
532    /// Load from a YAML file (read, interpolate, parse, validate).
533    pub fn from_path(path: &Path) -> Result<Self, ConfigError> {
534        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
535            path: path.to_owned(),
536            source,
537        })?;
538        Self::from_str(&text)
539    }
540
541    fn parse_interpolated(text: &str) -> Result<Self, ConfigError> {
542        let de = serde_yaml::Deserializer::from_str(text);
543        let mut cfg: PipelineConfig =
544            serde_path_to_error::deserialize(de).map_err(|e| ConfigError::Parse {
545                path: e.path().to_string(),
546                source: e.into_inner(),
547            })?;
548        cfg.source.set_section("source");
549        if let Some(sink) = cfg.sink.as_mut() {
550            sink.set_section("sink");
551        }
552        if let Some(sinks) = cfg.sinks.as_mut() {
553            for sink in sinks.values_mut() {
554                sink.set_section("sink");
555            }
556        }
557        if let Some(deser) = cfg.deserializer.as_mut() {
558            deser.set_section("deserializer");
559        }
560        cfg.validate()?;
561        Ok(cfg)
562    }
563
564    /// Cross-field validation, run automatically by the loaders. Public so
565    /// programmatically built configs (tests, `spate-test`) get the same
566    /// checks.
567    pub fn validate(&self) -> Result<(), ConfigError> {
568        let fail = |msg: String| Err(ConfigError::Validation(msg));
569
570        if self.pipeline.name.trim().is_empty() {
571            return fail("pipeline.name must not be empty".into());
572        }
573        if self.pipeline.io_threads == 0 {
574            return fail("pipeline.io_threads must be at least 1".into());
575        }
576        if self.pipeline.threads == Some(0) {
577            return fail("pipeline.threads must be at least 1 when set".into());
578        }
579        // The commit loop fires whenever `last_commit.elapsed() >= interval`,
580        // so an interval below a poll cycle commits on nearly every loop.
581        // A floor keeps sub-100ms intervals from hammering the source's
582        // offset store for no durability gain. The sink write is the
583        // durability boundary, not the checkpoint.
584        const MIN_COMMIT_INTERVAL: Duration = Duration::from_millis(100);
585        if self.checkpoint.interval < MIN_COMMIT_INTERVAL {
586            return fail(format!(
587                "checkpoint.interval must be at least 100ms (got {:?}): the commit \
588                 loop fires every interval, so sub-100ms intervals hammer the \
589                 source's offset store without improving durability",
590                self.checkpoint.interval
591            ));
592        }
593        if self.checkpoint.max_pending_batches == 0 {
594            return fail("checkpoint.max_pending_batches must be at least 1".into());
595        }
596        if self.checkpoint.drain_timeout.is_zero() {
597            return fail("checkpoint.drain_timeout must be greater than zero".into());
598        }
599        if self.checkpoint.stalled_fail_after.is_zero() {
600            return fail("checkpoint.stalled_fail_after must be greater than zero".into());
601        }
602        if self.backpressure.max_inflight_bytes.as_u64() == 0 {
603            return fail("backpressure.max_inflight_bytes must be greater than zero".into());
604        }
605        let (low, high) = (self.backpressure.low_ratio, self.backpressure.high_ratio);
606        if !(low > 0.0 && low < high && high <= 1.0) {
607            return fail(format!(
608                "backpressure ratios must satisfy 0 < low_ratio < high_ratio <= 1 \
609                 (got low_ratio={low}, high_ratio={high})"
610            ));
611        }
612        match (&self.sink, &self.sinks) {
613            (Some(_), Some(_)) => {
614                return fail("set exactly one of `sink:` or `sinks:`, not both".into());
615            }
616            (None, None) => {
617                return fail("a `sink:` or `sinks:` section is required".into());
618            }
619            (None, Some(map)) if map.is_empty() => {
620                return fail("`sinks:` must declare at least one sink".into());
621            }
622            _ => {}
623        }
624        if let Some(sinks) = &self.sinks {
625            for name in sinks.keys() {
626                if name.is_empty() {
627                    return fail("`sinks:` names must be non-empty".into());
628                }
629                // "default" maps to the historical component="sink" metric
630                // label, so a sink literally named "sink" would merge its
631                // series with the default's.
632                if name == "sink" {
633                    return fail(
634                        "the sink name \"sink\" is reserved (it is the default \
635                         sink's metric label); rename the `sinks:` entry"
636                            .into(),
637                    );
638                }
639            }
640        }
641        self.reject_stray_chunk()?;
642        // Resolve every declared sink's `chunk:` block now, so a malformed
643        // block is rejected at load, including for a `sinks:` entry this
644        // binary never installs (nothing later would resolve it).
645        if let Some(sink) = &self.sink {
646            sink.resolved_chunk()?;
647        }
648        if let Some(sinks) = &self.sinks {
649            for (name, sink) in sinks {
650                sink.resolved_chunk()
651                    .map_err(|e| name_sinks_entry_error(name, e))?;
652            }
653        }
654        Ok(())
655    }
656
657    /// Reject the framework-reserved `chunk:` key on a non-sink section.
658    /// `chunk:` configures the chain terminal, which only sinks have; the
659    /// framework peels the key indiscriminately, so a stray `chunk:` under
660    /// `source`/`deserializer` would otherwise be silently swallowed. Split
661    /// out of [`validate`](Self::validate) so `Pipeline::from_config`, which
662    /// skips full validation for minimal programmatic configs, can still
663    /// enforce it.
664    pub(crate) fn reject_stray_chunk(&self) -> Result<(), ConfigError> {
665        if self.source.resolved_chunk()?.is_some() {
666            return Err(ConfigError::Validation(
667                "`chunk:` is only valid on a sink section, not `source`".into(),
668            ));
669        }
670        if let Some(deser) = &self.deserializer
671            && deser.resolved_chunk()?.is_some()
672        {
673            return Err(ConfigError::Validation(
674                "`chunk:` is only valid on a sink section, not `deserializer`".into(),
675            ));
676        }
677        Ok(())
678    }
679
680    /// The component config for the sink named `name`. The single-sink `sink:`
681    /// form is addressed as `"default"`. A connector factory calls this once
682    /// per sink to build it.
683    ///
684    /// # Errors
685    ///
686    /// [`ConfigError::Validation`] if no sink is configured under `name`.
687    pub fn sink_config(&self, name: &str) -> Result<&ComponentConfig, ConfigError> {
688        if let Some(sinks) = &self.sinks {
689            sinks.get(name).ok_or_else(|| {
690                let known: Vec<&str> = sinks.keys().map(String::as_str).collect();
691                ConfigError::Validation(format!("no sink named {name:?} (configured: {known:?})"))
692            })
693        } else if name == "default" {
694            self.sink
695                .as_ref()
696                .ok_or_else(|| ConfigError::Validation("no sink configured".into()))
697        } else {
698            Err(ConfigError::Validation(format!(
699                "no sink named {name:?}: this pipeline configures a single `sink:` \
700                 (address it as \"default\")"
701            )))
702        }
703    }
704
705    /// The configured sink names, sorted. A single-sink config reports
706    /// `["default"]`.
707    #[must_use]
708    pub fn sink_names(&self) -> Vec<String> {
709        match &self.sinks {
710            Some(sinks) => sinks.keys().cloned().collect(),
711            None => vec!["default".to_string()],
712        }
713    }
714}
715
716/// Re-anchor a `sinks:` entry's chunk error onto its map key. The entry's
717/// section prefix is the shared `sink.<type>`, which cannot distinguish two
718/// entries of the same connector type. `sinks.<name>.<...>` can.
719fn name_sinks_entry_error(name: &str, e: ConfigError) -> ConfigError {
720    match e {
721        ConfigError::Validation(m) => ConfigError::Validation(format!("sinks.{name}: {m}")),
722        ConfigError::Component { context, message } => ConfigError::Component {
723            context: match context.strip_prefix("sink.") {
724                Some(rest) => format!("sinks.{name}.{rest}"),
725                None => format!("sinks.{name}.{context}"),
726            },
727            message,
728        },
729        other => other,
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    const MINIMAL: &str = r#"
738pipeline: { name: demo }
739source: { memory: {} }
740sink: { memory: {} }
741"#;
742
743    #[test]
744    fn minimal_config_applies_documented_defaults() {
745        let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
746        assert_eq!(cfg.pipeline.name, "demo");
747        assert_eq!(cfg.pipeline.threads, None);
748        assert_eq!(cfg.pipeline.io_threads, 2);
749        assert_eq!(cfg.pipeline.pinning, PinningMode::Off);
750        assert_eq!(cfg.checkpoint.interval, Duration::from_secs(5));
751        assert_eq!(cfg.checkpoint.max_pending_batches, 1024);
752        assert_eq!(cfg.checkpoint.drain_timeout, Duration::from_secs(25));
753        assert_eq!(cfg.checkpoint.stalled_fail_after, Duration::from_secs(120));
754        assert_eq!(cfg.backpressure.max_inflight_bytes, ByteSize::mib(256));
755        assert_eq!(cfg.backpressure.high_ratio, 0.8);
756        assert_eq!(cfg.backpressure.low_ratio, 0.5);
757        assert_eq!(cfg.backpressure.min_pause, Duration::from_millis(500));
758        assert_eq!(cfg.metrics.exporter, MetricsExporter::Prometheus);
759        assert!(!cfg.metrics.per_partition_detail);
760        assert_eq!(cfg.metrics.e2e_basis, E2eBasis::Ingest);
761        assert_eq!(
762            cfg.admin.listen,
763            Some(SocketAddr::from(([0, 0, 0, 0], 9090)))
764        );
765        assert!(cfg.deserializer.is_none());
766    }
767
768    /// `new` and the YAML defaults are two spellings of one config, so a
769    /// section added to the struct has to reach both.
770    #[test]
771    fn new_matches_the_yaml_defaults() {
772        let body = || YamlValue::Mapping(Default::default());
773        assert_eq!(
774            PipelineConfig::new(
775                PipelineSection::new("demo"),
776                ComponentConfig::new("memory", body()),
777                ComponentConfig::new("memory", body()),
778            ),
779            PipelineConfig::from_str(MINIMAL).unwrap()
780        );
781    }
782
783    /// `new_multi_sink` and the YAML defaults are two spellings of one config,
784    /// so a section added to the struct has to reach both.
785    #[test]
786    fn new_multi_sink_matches_the_yaml_defaults() {
787        let body = || YamlValue::Mapping(Default::default());
788        let sinks = BTreeMap::from([
789            ("eu".to_owned(), ComponentConfig::new("memory", body())),
790            ("us".to_owned(), ComponentConfig::new("memory", body())),
791        ]);
792        let yaml = "
793pipeline: { name: demo }
794source: { memory: {} }
795sinks:
796  eu: { memory: {} }
797  us: { memory: {} }
798";
799        assert_eq!(
800            PipelineConfig::new_multi_sink(
801                PipelineSection::new("demo"),
802                ComponentConfig::new("memory", body()),
803                sinks,
804            ),
805            PipelineConfig::from_str(yaml).unwrap()
806        );
807    }
808
809    /// A deserializer added through `with_deserializer` carries the section
810    /// tag the loader gives one, so the two spellings report the same error
811    /// paths.
812    #[test]
813    fn with_deserializer_matches_the_yaml_form() {
814        let body = || YamlValue::Mapping(Default::default());
815        let yaml = "
816pipeline: { name: demo }
817source: { memory: {} }
818deserializer: { json: {} }
819sink: { memory: {} }
820";
821        assert_eq!(
822            PipelineConfig::new(
823                PipelineSection::new("demo"),
824                ComponentConfig::new("memory", body()),
825                ComponentConfig::new("memory", body()),
826            )
827            .with_deserializer(ComponentConfig::new("json", body())),
828            PipelineConfig::from_str(yaml).unwrap()
829        );
830    }
831
832    /// A constructed config passes the validation the loaders run, so `new`
833    /// cannot produce one `from_str` would have rejected.
834    #[test]
835    fn a_constructed_config_validates() {
836        let body = || YamlValue::Mapping(Default::default());
837        PipelineConfig::new(
838            PipelineSection::new("demo"),
839            ComponentConfig::new("memory", body()),
840            ComponentConfig::new("memory", body()),
841        )
842        .validate()
843        .expect("the single-sink form validates");
844
845        PipelineConfig::new_multi_sink(
846            PipelineSection::new("demo"),
847            ComponentConfig::new("memory", body()),
848            BTreeMap::from([("eu".to_owned(), ComponentConfig::new("memory", body()))]),
849        )
850        .validate()
851        .expect("the multi-sink form validates");
852    }
853
854    #[test]
855    fn admin_listen_takes_an_address_or_none() {
856        let with = |admin: &str| {
857            PipelineConfig::from_str(&format!(
858                "pipeline: {{ name: demo }}\n{admin}\nsource: {{ memory: {{}} }}\n\
859                 sink: {{ memory: {{}} }}\n"
860            ))
861        };
862
863        let bound = with("admin: { listen: 127.0.0.1:7777 }").expect("an address parses");
864        assert_eq!(
865            bound.admin.listen,
866            Some(SocketAddr::from(([127, 0, 0, 1], 7777)))
867        );
868
869        let off = with("admin: { listen: none }").expect("`none` parses");
870        assert_eq!(off.admin.listen, None, "`none` asks for no server");
871
872        // The error names the key and both accepted forms; without the path
873        // a reader cannot tell which of several addresses in a file is wrong.
874        let err = with("admin: { listen: 9090 }").expect_err("a bare port is not an address");
875        let msg = err.to_string();
876        assert!(msg.contains("admin.listen"), "{msg}");
877        assert!(msg.contains(r#"a socket address or "none""#), "{msg}");
878
879        // A YAML null is not a spelling of `none`. It has to say so in the
880        // same terms as every other rejected value, or the one reader who
881        // reaches for `~` gets a type error naming a type they never wrote.
882        for null in [
883            "admin: { listen: ~ }",
884            "admin: { listen: null }",
885            "admin:\n  listen:",
886        ] {
887            let msg = with(null)
888                .expect_err("a null is not an address")
889                .to_string();
890            assert!(
891                msg.contains(r#"a socket address or "none""#),
892                "{null}: {msg}"
893            );
894        }
895    }
896
897    /// The bind address is `admin.listen`, and `metrics` carries no address
898    /// of its own. A file placing one there fails to load rather than parsing
899    /// into a pipeline whose server is somewhere else.
900    #[test]
901    fn the_metrics_section_takes_no_bind_address() {
902        let err = PipelineConfig::from_str(
903            "pipeline: { name: demo }\nmetrics: { listen: 0.0.0.0:9090 }\n\
904             source: { memory: {} }\nsink: { memory: {} }\n",
905        )
906        .expect_err("metrics carries no bind address");
907        let msg = err.to_string();
908        assert!(msg.contains("listen"), "{msg}");
909    }
910
911    #[test]
912    fn sink_chunk_block_parses_and_resolves() {
913        let yaml = r#"
914pipeline: { name: demo }
915source: { memory: {} }
916sink:
917  memory:
918    chunk: { target_bytes: 512KiB, encode_policy: fail }
919"#;
920        let cfg = PipelineConfig::from_str(yaml).unwrap();
921        let chunk = cfg
922            .sink_config("default")
923            .unwrap()
924            .resolved_chunk()
925            .unwrap()
926            .expect("chunk present");
927        assert_eq!(chunk.target_bytes, 512 * 1024);
928        assert_eq!(chunk.encode_policy, crate::error::ErrorPolicy::Fail);
929    }
930
931    #[test]
932    fn chunk_on_a_source_section_is_rejected() {
933        let yaml = r#"
934pipeline: { name: demo }
935source:
936  memory:
937    chunk: { target_bytes: 64KiB }
938sink: { memory: {} }
939"#;
940        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
941        assert!(err.contains("chunk"), "{err}");
942        assert!(err.contains("source"), "{err}");
943    }
944
945    #[test]
946    fn zero_target_bytes_in_yaml_is_rejected_at_load() {
947        let yaml = r#"
948pipeline: { name: demo }
949source: { memory: {} }
950sink:
951  memory:
952    chunk: { target_bytes: 0B }
953"#;
954        // `validate` resolves every declared sink chunk, so the loader itself
955        // rejects it. "Rejected at load" in the reference docs is literal.
956        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
957        assert!(err.contains("chunk.target_bytes"), "{err}");
958    }
959
960    #[test]
961    fn malformed_chunk_on_a_sinks_entry_is_rejected_at_load_naming_the_entry() {
962        // Two entries of the same connector type share the dotted path
963        // `sink.memory.…`, so the error must be re-anchored on the map key,
964        // and an entry must fail at load even if no binary ever installs it.
965        let yaml = r#"
966pipeline: { name: demo }
967source: { memory: {} }
968sinks:
969  hot: { memory: {} }
970  cold:
971    memory:
972      chunk: { encode_policy: retry }
973"#;
974        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
975        assert!(err.contains("sinks.cold"), "{err}");
976        let yaml = r#"
977pipeline: { name: demo }
978source: { memory: {} }
979sinks:
980  cold:
981    memory:
982      chunk: { target_bytes: 0B }
983"#;
984        let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
985        assert!(err.contains("sinks.cold"), "{err}");
986        assert!(err.contains("chunk.target_bytes"), "{err}");
987    }
988
989    #[test]
990    fn full_design_doc_example_parses() {
991        // The connector bodies below mirror the module-doc example and use the
992        // real connector field names. spate-core has no dependency on the
993        // connector crates, so this test only parses the framework layer.
994        let yaml = r#"
995pipeline: { name: orders, threads: 4, io_threads: 2 }
996checkpoint: { interval: 5s, max_pending_batches: 1024 }
997backpressure: { max_inflight_bytes: 256MiB }
998source:
999  kafka:
1000    brokers: ${KAFKA_BROKERS:-localhost:9092}
1001    topic: orders
1002    group_id: orders-etl
1003    rdkafka: { fetch.message.max.bytes: "1048576" }
1004deserializer:
1005  avro:
1006    mode: confluent
1007    registry:
1008      url: "${SCHEMA_REGISTRY_URL:-http://sr:8081}"
1009sink:
1010  clickhouse:
1011    table: orders_local
1012    columns: [id, amount, ts]
1013    shards:
1014      - { replicas: ["http://ch-0-0:8123", "http://ch-0-1:8123"] }
1015      - { replicas: ["http://ch-1-0:8123", "http://ch-1-1:8123"] }
1016    batch: { max_rows: 500000, max_bytes: 128MiB, linger: 1s }
1017    inflight: { max_per_shard: 2 }
1018    retry: { initial: 100ms, max: 10s, multiplier: 2.0 }
1019admin: { listen: 0.0.0.0:9090 }
1020metrics: { exporter: prometheus }
1021"#;
1022        let cfg = PipelineConfig::from_str(yaml).unwrap();
1023        assert_eq!(cfg.pipeline.threads, Some(4));
1024        assert_eq!(cfg.source.type_tag(), "kafka");
1025        assert_eq!(cfg.deserializer.as_ref().unwrap().type_tag(), "avro");
1026        assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "clickhouse");
1027
1028        // Interpolated default landed inside the opaque body, and the kafka
1029        // body carries the required group_id.
1030        #[derive(Debug, serde::Deserialize)]
1031        struct KafkaProbe {
1032            brokers: String,
1033            group_id: String,
1034            #[serde(flatten)]
1035            _rest: serde_yaml::Value,
1036        }
1037        let kafka: KafkaProbe = cfg.source.deserialize_into().unwrap();
1038        assert_eq!(kafka.brokers, "localhost:9092");
1039        assert_eq!(kafka.group_id, "orders-etl");
1040
1041        // The avro body uses the nested `registry.url` shape, and the
1042        // clickhouse body carries the required `columns`.
1043        #[derive(Debug, serde::Deserialize)]
1044        struct AvroProbe {
1045            registry: RegistryProbe,
1046        }
1047        #[derive(Debug, serde::Deserialize)]
1048        struct RegistryProbe {
1049            url: String,
1050        }
1051        let avro: AvroProbe = cfg
1052            .deserializer
1053            .as_ref()
1054            .unwrap()
1055            .deserialize_into()
1056            .unwrap();
1057        assert_eq!(avro.registry.url, "http://sr:8081");
1058
1059        #[derive(Debug, serde::Deserialize)]
1060        struct ChProbe {
1061            columns: Vec<String>,
1062        }
1063        let ch: ChProbe = cfg
1064            .sink_config("default")
1065            .unwrap()
1066            .deserialize_into()
1067            .unwrap();
1068        assert_eq!(ch.columns, ["id", "amount", "ts"]);
1069    }
1070
1071    #[test]
1072    fn single_sink_resolves_as_default() {
1073        let cfg = PipelineConfig::from_str(MINIMAL).unwrap();
1074        assert_eq!(cfg.sink_names(), vec!["default".to_string()]);
1075        assert_eq!(cfg.sink_config("default").unwrap().type_tag(), "memory");
1076        assert!(cfg.sink_config("other").is_err());
1077    }
1078
1079    #[test]
1080    fn sinks_map_parses_and_resolves_by_name() {
1081        let yaml = r#"
1082pipeline: { name: demo }
1083source: { memory: {} }
1084sinks:
1085  type_a: { memory: {} }
1086  type_b: { memory: {} }
1087"#;
1088        let cfg = PipelineConfig::from_str(yaml).unwrap();
1089        assert_eq!(
1090            cfg.sink_names(),
1091            vec!["type_a".to_string(), "type_b".to_string()]
1092        );
1093        assert_eq!(cfg.sink_config("type_a").unwrap().type_tag(), "memory");
1094        assert_eq!(cfg.sink_config("type_b").unwrap().type_tag(), "memory");
1095        // The single-sink alias is not present in a `sinks:` config.
1096        assert!(cfg.sink_config("default").is_err());
1097    }
1098
1099    #[test]
1100    fn sink_and_sinks_are_mutually_exclusive() {
1101        let both = r#"
1102pipeline: { name: demo }
1103source: { memory: {} }
1104sink: { memory: {} }
1105sinks:
1106  a: { memory: {} }
1107"#;
1108        assert!(matches!(
1109            PipelineConfig::from_str(both),
1110            Err(ConfigError::Validation(_))
1111        ));
1112
1113        let neither = r#"
1114pipeline: { name: demo }
1115source: { memory: {} }
1116"#;
1117        assert!(matches!(
1118            PipelineConfig::from_str(neither),
1119            Err(ConfigError::Validation(_))
1120        ));
1121
1122        let empty = r#"
1123pipeline: { name: demo }
1124source: { memory: {} }
1125sinks: {}
1126"#;
1127        assert!(matches!(
1128            PipelineConfig::from_str(empty),
1129            Err(ConfigError::Validation(_))
1130        ));
1131    }
1132
1133    #[test]
1134    fn reserved_sink_names_are_rejected() {
1135        // "sink" is the default sink's metric label; a `sinks:` entry under
1136        // that name would merge its series with a default sink's.
1137        let reserved = r#"
1138pipeline: { name: demo }
1139source: { memory: {} }
1140sinks:
1141  sink: { memory: {} }
1142"#;
1143        assert!(matches!(
1144            PipelineConfig::from_str(reserved),
1145            Err(ConfigError::Validation(msg)) if msg.contains("reserved")
1146        ));
1147
1148        let empty_name = r#"
1149pipeline: { name: demo }
1150source: { memory: {} }
1151sinks:
1152  "": { memory: {} }
1153"#;
1154        assert!(matches!(
1155            PipelineConfig::from_str(empty_name),
1156            Err(ConfigError::Validation(msg)) if msg.contains("non-empty")
1157        ));
1158    }
1159
1160    #[test]
1161    fn unknown_fields_are_rejected_at_every_typed_level() {
1162        for (yaml, field) in [
1163            (
1164                "pipeline: { name: x, bogus: 1 }\nsource: { m: {} }\nsink: { m: {} }",
1165                "bogus",
1166            ),
1167            (
1168                "pipeline: { name: x }\ncheckpoint: { intervall: 5s }\nsource: { m: {} }\nsink: { m: {} }",
1169                "intervall",
1170            ),
1171            (
1172                "pipeline: { name: x }\nmetrics: { port: 9 }\nsource: { m: {} }\nsink: { m: {} }",
1173                "port",
1174            ),
1175            (
1176                "pipeline: { name: x }\nsource: { m: {} }\nsink: { m: {} }\nsinks: {}",
1177                "sinks",
1178            ),
1179        ] {
1180            let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1181            assert!(err.contains(field), "expected `{field}` in error: {err}");
1182        }
1183    }
1184
1185    #[test]
1186    fn parse_errors_carry_the_yaml_path() {
1187        let yaml = "pipeline: { name: x, io_threads: many }\nsource: { m: {} }\nsink: { m: {} }";
1188        let err = PipelineConfig::from_str(yaml).unwrap_err();
1189        let text = err.to_string();
1190        assert!(text.contains("pipeline.io_threads"), "{text}");
1191    }
1192
1193    #[test]
1194    fn validation_rules() {
1195        let cases = [
1196            (
1197                "pipeline: { name: '  ' }\nsource: { m: {} }\nsink: { m: {} }",
1198                "pipeline.name",
1199            ),
1200            (
1201                "pipeline: { name: x, io_threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1202                "io_threads",
1203            ),
1204            (
1205                "pipeline: { name: x, threads: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1206                "threads",
1207            ),
1208            (
1209                "pipeline: { name: x }\ncheckpoint: { interval: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1210                "interval",
1211            ),
1212            (
1213                "pipeline: { name: x }\ncheckpoint: { interval: 50ms }\nsource: { m: {} }\nsink: { m: {} }",
1214                "at least 100ms",
1215            ),
1216            (
1217                "pipeline: { name: x }\ncheckpoint: { max_pending_batches: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1218                "max_pending_batches",
1219            ),
1220            (
1221                "pipeline: { name: x }\ncheckpoint: { drain_timeout: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1222                "drain_timeout",
1223            ),
1224            (
1225                "pipeline: { name: x }\ncheckpoint: { stalled_fail_after: 0s }\nsource: { m: {} }\nsink: { m: {} }",
1226                "stalled_fail_after",
1227            ),
1228            (
1229                "pipeline: { name: x }\nbackpressure: { max_inflight_bytes: 0 }\nsource: { m: {} }\nsink: { m: {} }",
1230                "max_inflight_bytes",
1231            ),
1232            (
1233                "pipeline: { name: x }\nbackpressure: { low_ratio: 0.9, high_ratio: 0.8 }\nsource: { m: {} }\nsink: { m: {} }",
1234                "low_ratio",
1235            ),
1236            (
1237                "pipeline: { name: x }\nbackpressure: { high_ratio: 1.5 }\nsource: { m: {} }\nsink: { m: {} }",
1238                "high_ratio",
1239            ),
1240        ];
1241        for (yaml, needle) in cases {
1242            let err = PipelineConfig::from_str(yaml).unwrap_err().to_string();
1243            assert!(err.contains(needle), "expected `{needle}` in: {err}");
1244        }
1245    }
1246
1247    #[test]
1248    fn missing_required_sections_error_clearly() {
1249        let err = PipelineConfig::from_str("pipeline: { name: x }\nsink: { m: {} }")
1250            .unwrap_err()
1251            .to_string();
1252        assert!(err.contains("source"), "{err}");
1253        let err = PipelineConfig::from_str("source: { m: {} }\nsink: { m: {} }")
1254            .unwrap_err()
1255            .to_string();
1256        assert!(err.contains("pipeline"), "{err}");
1257    }
1258
1259    #[test]
1260    fn interpolation_failures_surface_with_position() {
1261        let err = PipelineConfig::from_str("pipeline:\n  name: ${UNSET_VAR_FOR_TEST}\n")
1262            .unwrap_err()
1263            .to_string();
1264        assert!(err.contains("UNSET_VAR_FOR_TEST"), "{err}");
1265        assert!(err.contains("line 2"), "{err}");
1266    }
1267
1268    #[test]
1269    fn from_path_reads_interpolates_and_reports_io_errors() {
1270        use std::io::Write as _;
1271        let mut file = tempfile::NamedTempFile::new().unwrap();
1272        write!(
1273            file,
1274            "pipeline: {{ name: ${{FILE_TEST_NAME:-from-file}} }}\nsource: {{ m: {{}} }}\nsink: {{ m: {{}} }}\n"
1275        )
1276        .unwrap();
1277        let cfg = PipelineConfig::from_path(file.path()).unwrap();
1278        assert_eq!(cfg.pipeline.name, "from-file");
1279
1280        let err = PipelineConfig::from_path(Path::new("/nonexistent/spate.yaml")).unwrap_err();
1281        assert!(matches!(err, ConfigError::Io { .. }));
1282        assert!(err.to_string().contains("/nonexistent/spate.yaml"));
1283    }
1284
1285    #[test]
1286    fn equal_inputs_parse_to_equal_configs() {
1287        let a = PipelineConfig::from_str(MINIMAL).unwrap();
1288        let b = PipelineConfig::from_str(MINIMAL).unwrap();
1289        assert_eq!(a, b);
1290        let c = PipelineConfig::from_str(
1291            "pipeline: { name: other }\nsource: { m: {} }\nsink: { m: {} }",
1292        )
1293        .unwrap();
1294        assert_ne!(a, c);
1295    }
1296}