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