Skip to main content

faucet_cli/
config.rs

1//! Parsed `pipeline.yaml` / `pipeline.json` schema (matrix-aware).
2//!
3//! Top-level shape:
4//!
5//! ```yaml
6//! version: 1
7//! name: optional-human-name
8//! pipeline:               # required — full base config
9//!   source: { type, config }
10//!   sink:   { type, config }
11//!   transforms: [...]
12//!   state:  { type, config }
13//! matrix:                 # optional — omitted or empty == one anonymous row
14//!   - id: <string>
15//!     parent: <id>
16//!     parent_key: <jsonpath>   # default "id"
17//!     source: { ... }     # partial override, deep-merged into pipeline.source
18//!     sink:   { ... }
19//!     transforms: [...]   # row-level transforms, appended after pipeline + source layers
20//!     state:  { ... }     # if Some, replaces pipeline.state wholesale
21//! execution:              # optional
22//!   max_concurrent: <usize>
23//!   on_error: continue|stop
24//! ```
25//!
26//! The wire format is intentionally loose: every connector keeps its own
27//! config schema, and the CLI threads a `serde_json::Value` through to the
28//! connector's `serde::Deserialize` impl. That keeps this struct stable as
29//! new fields are added to individual connectors without needing CLI work.
30
31use crate::error::{CliError, CliResult};
32use schemars::JsonSchema;
33use serde::{Deserialize, Serialize};
34use serde_json::Value;
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37
38/// Top-level pipeline definition.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(deny_unknown_fields)]
41pub struct PipelineConfig {
42    /// Config-format version. Currently always `1`.
43    #[serde(default = "default_version")]
44    pub version: u32,
45
46    /// Optional human-readable name (used in logs and error messages).
47    #[serde(default)]
48    pub name: Option<String>,
49
50    /// Optional shared constants. Resolvable as `${vars.key}` anywhere in
51    /// the config (including inside named templates). Resolved at load time,
52    /// after env/file/secret substitution.
53    #[serde(default)]
54    pub vars: Option<HashMap<String, Value>>,
55
56    /// Optional named auth providers. Each entry is a `{ type, config }` spec
57    /// (the same shape as inline auth) built once and shared across every
58    /// connector that references it via `auth: { ref: <name> }`. Values are kept
59    /// as raw JSON so `faucet-auth` owns the per-type schema.
60    #[serde(default)]
61    pub auth: Option<HashMap<String, Value>>,
62
63    /// Base pipeline — every matrix row is deep-merged into this.
64    pub pipeline: PipelineSpec,
65
66    /// Matrix of per-row overrides. Empty or omitted means "one anonymous row"
67    /// (full pipeline runs once with no merge).
68    #[serde(default)]
69    pub matrix: Vec<MatrixRow>,
70
71    /// Optional execution controls (concurrency, on-error policy).
72    #[serde(default)]
73    pub execution: Option<ExecutionSpec>,
74
75    /// Optional observability configuration (Prometheus + tracing).
76    #[serde(default)]
77    pub observability: Option<ObservabilitySpec>,
78
79    /// Delivery guarantee for every row (overridable per matrix row). Default
80    /// `at_least_once` — no behaviour change for existing configs. `exactly_once`
81    /// requires an idempotent sink, a deterministic-replay source, and a state
82    /// store (enforced at expand time).
83    #[serde(default)]
84    pub delivery: faucet_core::DeliveryMode,
85
86    /// Optional resilience policy (retry / backoff / circuit-breaker /
87    /// poison-pill). Top-level in v1 (not per-matrix-row). Absent = no behaviour
88    /// change. Consumed by `faucet run`/`schedule`/`replicate`.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub resilience: Option<ResilienceSpec>,
91
92    /// Optional source-shard distribution for clustered (Mode B) execution.
93    /// Only consumed by `faucet serve --cluster`: a run whose source
94    /// [is shardable](faucet_core::Source::is_shardable) is split into
95    /// `shard.count` shards processed concurrently across cluster workers.
96    /// Ignored by `faucet run` and by a non-cluster `serve`, so it is fully
97    /// backward compatible.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub shard: Option<ShardingSpec>,
100
101    /// Optional snapshot→CDC replication block. Consumed only by
102    /// `faucet replicate`; ignored by `faucet run` (like `schedule:`).
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub replication: Option<crate::replication::spec::ReplicationSpec>,
105
106    /// Optional cron schedule. Only consumed by `faucet schedule`; ignored by
107    /// `faucet run`. Presence makes the config runnable on a schedule.
108    #[cfg(feature = "schedule")]
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
111
112    /// Optional OpenLineage emission. Consumed by `faucet run`/`schedule`/`serve`.
113    #[cfg(feature = "lineage")]
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub lineage: Option<faucet_lineage::LineageConfig>,
116}
117
118/// The base pipeline definition. Each matrix row is resolved against the
119/// template catalogs below; the singular `source` / `sink` fields are the
120/// legacy way to declare a single template (internally named `default`).
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct PipelineSpec {
124    /// Legacy singular source — registers as a template named `default`.
125    /// Defining both `source` and `sources.default` is an error at expand time.
126    #[serde(default)]
127    pub source: Option<ConnectorSpec>,
128
129    /// Legacy singular sink — registers as a template named `default`.
130    #[serde(default)]
131    pub sink: Option<ConnectorSpec>,
132
133    /// Named source templates. A matrix row picks one via `source.ref: NAME`.
134    #[serde(default)]
135    pub sources: HashMap<String, ConnectorSpec>,
136
137    /// Named sink templates. A matrix row picks one via `sink.ref: NAME`.
138    #[serde(default)]
139    pub sinks: HashMap<String, ConnectorSpec>,
140
141    #[serde(default)]
142    pub transforms: Vec<TransformSpec>,
143    #[serde(default)]
144    pub state: Option<StateStoreSpec>,
145    #[serde(default)]
146    pub dlq: Option<DlqSpec>,
147
148    /// Data-quality checks (pipeline-level; no matrix-row override in v1).
149    #[cfg(feature = "quality")]
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub quality: Option<faucet_core::QualitySpec>,
152
153    /// Schema-drift handling policy (pipeline-level; no matrix-row override in v1).
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub schema: Option<faucet_core::SchemaDriftSpec>,
156}
157
158/// A `{ type, config }` block, the universal shape for both sources and sinks.
159///
160/// Source templates may additionally carry `transforms:` and
161/// `inherit_transforms:`. Both are rejected on sink templates at expand time.
162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
163#[serde(deny_unknown_fields)]
164pub struct ConnectorSpec {
165    /// Connector type — matches the suffix of the underlying crate
166    /// (e.g. `rest` for `faucet-source-rest`).
167    #[serde(rename = "type")]
168    pub kind: String,
169
170    /// Connector-specific config object. Passed through verbatim to the
171    /// connector's `serde::Deserialize` impl.
172    #[serde(default = "empty_object")]
173    pub config: Value,
174
175    /// Transforms bound to this source template. Applied after `T_pipeline`
176    /// and before `T_row` for every matrix row that resolves to this template.
177    /// Rejected at expand time when this `ConnectorSpec` is used as a sink.
178    #[serde(default)]
179    pub transforms: Option<Vec<TransformSpec>>,
180
181    /// When `false`, drops upstream `T_pipeline` transforms for every matrix
182    /// row that resolves to this source template. Default `true`. Rejected
183    /// at expand time on sinks.
184    #[serde(default = "default_true")]
185    pub inherit_transforms: bool,
186}
187
188/// A partial connector override carried by a matrix row. Both `type` and
189/// `config` are optional so rows can swap the kind, override only the inner
190/// config, or both. `ref:` (optional) picks which named template under
191/// `pipeline.sources` / `pipeline.sinks` this row instantiates; when absent,
192/// the row inherits the legacy singular `pipeline.source` / `pipeline.sink`
193/// (registered internally as a template named `default`).
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(deny_unknown_fields)]
196pub struct PartialConnector {
197    /// Name of the template under `pipeline.sources` / `pipeline.sinks` to
198    /// instantiate. `None` falls back to the `default` template.
199    #[serde(default)]
200    pub r#ref: Option<String>,
201    /// Override the connector kind (otherwise inherits from the template).
202    #[serde(rename = "type", default)]
203    pub kind: Option<String>,
204    /// Partial config object — deep-merged into the resolved template's config.
205    #[serde(default)]
206    pub config: Option<Value>,
207}
208
209/// A single transform declaration.
210#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
211#[serde(deny_unknown_fields)]
212pub struct TransformSpec {
213    /// Built-in transform identifier. One of: `flatten`, `rename_keys`,
214    /// `snake_case`, `select`, `drop`, `set`, `rename_field`, `cast`, `redact`,
215    /// `value_case`. See the docs-site cookbook page on transforms for
216    /// per-transform config schemas.
217    #[serde(rename = "type")]
218    pub kind: String,
219
220    /// Transform-specific config object (e.g. `{ separator: "__" }` for flatten).
221    #[serde(default = "empty_object")]
222    pub config: Value,
223}
224
225/// State-store backend selector.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub struct StateStoreSpec {
229    /// Store type: `file`, `memory`, `redis`, or `postgres`.
230    #[serde(rename = "type")]
231    pub kind: String,
232
233    /// Store-specific config.
234    #[serde(default = "empty_object")]
235    pub config: Value,
236}
237
238/// One row of the `matrix:` block.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct MatrixRow {
242    /// Row identifier. Required for parent/child references and runtime
243    /// `${id.path}` interpolation. Anonymous rows get a synthetic `row-N` id.
244    #[serde(default)]
245    pub id: Option<String>,
246
247    /// If set, this row runs once per record produced by the named parent row.
248    #[serde(default)]
249    pub parent: Option<String>,
250
251    /// Dotted field path inside each parent record that uniquely identifies
252    /// the record. Used as the state-key suffix. Default: `id`.
253    #[serde(default = "default_parent_key")]
254    pub parent_key: String,
255
256    /// Partial override of `pipeline.source` (deep-merged).
257    #[serde(default)]
258    pub source: Option<PartialConnector>,
259
260    /// Partial override of `pipeline.sink` (deep-merged).
261    #[serde(default)]
262    pub sink: Option<PartialConnector>,
263
264    /// Row-level transforms. Appended after `T_pipeline` and `T_source`
265    /// (unless `inherit_transforms` is `false` here, in which case both
266    /// upstream layers are dropped). `None` or empty list contributes
267    /// nothing.
268    #[serde(default)]
269    pub transforms: Option<Vec<TransformSpec>>,
270
271    /// When `false`, drops upstream `T_pipeline` and `T_source` transforms
272    /// for this row. Default `true`.
273    #[serde(default = "default_true")]
274    pub inherit_transforms: bool,
275
276    /// If `Some`, replaces `pipeline.state` wholesale.
277    #[serde(default)]
278    pub state: Option<StateStoreSpec>,
279
280    /// Matrix-row override semantics:
281    /// - field absent  → `None`     — inherit from `pipeline.dlq`
282    /// - `dlq: null`   → `Some(None)` — disable DLQ for this row
283    /// - `dlq: { ... }` → `Some(Some(spec))` — replace base DLQ wholesale
284    #[serde(default, deserialize_with = "deserialize_dlq_override")]
285    pub dlq: Option<Option<DlqSpec>>,
286
287    /// Per-row delivery override. `None` inherits the top-level `delivery`.
288    #[serde(default)]
289    pub delivery: Option<faucet_core::DeliveryMode>,
290}
291
292/// Execution-time controls.
293#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
294#[serde(deny_unknown_fields)]
295pub struct ExecutionSpec {
296    /// Maximum concurrent pipeline invocations (root + per-parent-record
297    /// child invocations all share this budget). Defaults to
298    /// `num_cpus::get().min(4)` at runtime when `None`.
299    #[serde(default)]
300    pub max_concurrent: Option<usize>,
301
302    /// What to do when a pipeline invocation fails.
303    #[serde(default)]
304    pub on_error: OnError,
305
306    /// Adaptive batch-size controller (opt-in). See `faucet_core::AdaptiveBatchConfig`.
307    #[serde(default)]
308    pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
309}
310
311/// Source-shard distribution settings for clustered (Mode B) execution.
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313#[serde(deny_unknown_fields)]
314pub struct ShardingSpec {
315    /// Target number of shards to split the source into. Must be `>= 2` (a
316    /// count of 1 means "don't shard" — omit the block instead). The actual
317    /// shard count may be smaller when the source has fewer natural partitions
318    /// (e.g. a key range narrower than `count`).
319    pub count: usize,
320}
321
322/// Failure-handling policy across the matrix.
323#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
324#[serde(rename_all = "lowercase")]
325pub enum OnError {
326    /// Skip the failed invocation's subtree but keep running siblings (default).
327    #[default]
328    Continue,
329    /// Cancel every pending and in-flight invocation on first failure.
330    Stop,
331}
332
333/// Top-level observability block: Prometheus scrape endpoint and tracing level.
334#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
335pub struct ObservabilitySpec {
336    /// Prometheus metrics scrape endpoint configuration.
337    #[serde(default)]
338    pub prometheus: Option<PrometheusSpec>,
339
340    /// Tracing / logging configuration.
341    #[serde(default)]
342    pub tracing: Option<TracingSpec>,
343
344    /// OTLP (OpenTelemetry) export configuration (#201).
345    #[serde(default)]
346    pub otel: Option<OtelSpec>,
347}
348
349/// Configuration for the Prometheus metrics HTTP endpoint.
350#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
351pub struct PrometheusSpec {
352    /// Socket address to bind the scrape endpoint on (e.g. `"127.0.0.1:9464"`).
353    pub listen: String,
354
355    /// Custom histogram bucket boundaries. Falls back to the Prometheus default
356    /// buckets when `None`.
357    #[serde(default)]
358    pub buckets: Option<Vec<f64>>,
359}
360
361/// Tracing / log-level configuration.
362#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363pub struct TracingSpec {
364    /// `tracing-subscriber` filter directive (e.g. `"info"`, `"debug"`,
365    /// `"faucet=trace"`). Defaults to the value of `RUST_LOG` when `None`.
366    #[serde(default)]
367    pub level: Option<String>,
368}
369
370/// OTLP export block under `observability.otel:`.
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372pub struct OtelSpec {
373    /// Collector endpoint URL. When empty, defaults to the protocol-specific
374    /// localhost address (`http://localhost:4317` for gRPC, `:4318` for HTTP).
375    #[serde(default)]
376    pub endpoint: String,
377    /// OTLP transport protocol (`grpc` or `http`). Default: `grpc`.
378    #[serde(default)]
379    pub protocol: faucet_core::OtelProtocol,
380    /// Extra headers sent on every export request (e.g. backend auth tokens).
381    #[serde(default)]
382    pub headers: std::collections::HashMap<String, String>,
383    /// Head-based trace sampling ratio, 0.0..=1.0. Default: 1.0 (sample all).
384    #[serde(default = "default_otel_ratio")]
385    pub sample_ratio: f64,
386    /// Which signals to export. Default: `[traces, metrics]`.
387    #[serde(default = "default_otel_export")]
388    pub export: Vec<faucet_core::OtelSignal>,
389    /// OTel resource `service.name`. Default: `"faucet"`.
390    #[serde(default = "default_otel_service")]
391    pub service_name: String,
392    /// Per-export timeout in seconds. Default: 10.
393    #[serde(default = "default_otel_timeout")]
394    pub timeout_secs: u64,
395    /// Metric push interval in seconds. Default: 60.
396    #[serde(default = "default_otel_interval")]
397    pub metric_interval_secs: u64,
398}
399
400fn default_otel_ratio() -> f64 {
401    1.0
402}
403fn default_otel_export() -> Vec<faucet_core::OtelSignal> {
404    vec![
405        faucet_core::OtelSignal::Traces,
406        faucet_core::OtelSignal::Metrics,
407    ]
408}
409fn default_otel_service() -> String {
410    "faucet".to_string()
411}
412fn default_otel_timeout() -> u64 {
413    10
414}
415fn default_otel_interval() -> u64 {
416    60
417}
418
419impl OtelSpec {
420    /// Convert to the core config and validate ranges/URL.
421    pub fn to_core(&self) -> Result<faucet_core::OtelConfig, String> {
422        let cfg = faucet_core::OtelConfig {
423            endpoint: self.endpoint.clone(),
424            protocol: self.protocol,
425            headers: self.headers.clone(),
426            sample_ratio: self.sample_ratio,
427            export: self.export.clone(),
428            service_name: self.service_name.clone(),
429            timeout_secs: self.timeout_secs,
430            metric_interval_secs: self.metric_interval_secs,
431        };
432        cfg.validate()?;
433        Ok(cfg)
434    }
435}
436
437/// Mirrors `faucet_core::OnBatchError` but with `JsonSchema` derived and
438/// `Deserialize` accepting the YAML/JSON shape. Converted to the core
439/// type during `executor::build_dlq_config`.
440#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
441#[serde(rename_all = "snake_case")]
442pub enum OnBatchErrorSpec {
443    #[default]
444    Propagate,
445    DlqAll,
446}
447
448/// DLQ configuration block under `pipeline.dlq:`.
449#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
450#[serde(deny_unknown_fields)]
451pub struct DlqSpec {
452    pub sink: ConnectorSpec,
453    #[serde(default)]
454    pub on_batch_error: OnBatchErrorSpec,
455    #[serde(default)]
456    pub max_failures_per_page: Option<usize>,
457    #[serde(default)]
458    pub max_failures_total: Option<usize>,
459    #[serde(default = "default_true")]
460    pub include_original_payload: bool,
461}
462
463/// User-facing `resilience:` config. Maps to `faucet_core::ResiliencePolicy`.
464#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
465#[serde(deny_unknown_fields)]
466pub struct ResilienceSpec {
467    /// Retry/backoff applied to sink-write, flush, and state-store I/O (and
468    /// injected into rest/xml/graphql sources).
469    #[serde(default)]
470    pub retry: RetrySpec,
471    /// Which error classes are retried. Omitted = all four transient classes.
472    #[serde(default)]
473    pub retry_on: Option<Vec<faucet_core::RetryClass>>,
474    /// Optional circuit breaker.
475    #[serde(default)]
476    pub circuit_breaker: Option<CircuitBreakerSpec>,
477    /// Optional poison-pill (per-row) handling (DLQ path only).
478    #[serde(default)]
479    pub poison: Option<PoisonSpec>,
480}
481
482/// Retry/backoff tuning.
483#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
484#[serde(deny_unknown_fields)]
485pub struct RetrySpec {
486    /// Total attempts including the first (1 = no retry).
487    #[serde(default = "default_max_attempts")]
488    pub max_attempts: u32,
489    /// Backoff growth shape.
490    #[serde(default)]
491    pub backoff: BackoffSpec,
492    /// Base delay in milliseconds.
493    #[serde(default = "default_base_ms")]
494    pub base_ms: u64,
495    /// Per-sleep cap in milliseconds (pre-jitter).
496    #[serde(default = "default_max_ms")]
497    pub max_ms: u64,
498    /// Whether to apply `[0.5, 1.5)` jitter.
499    #[serde(default = "default_true")]
500    pub jitter: bool,
501}
502
503impl Default for RetrySpec {
504    fn default() -> Self {
505        Self {
506            max_attempts: default_max_attempts(),
507            backoff: BackoffSpec::default(),
508            base_ms: default_base_ms(),
509            max_ms: default_max_ms(),
510            jitter: true,
511        }
512    }
513}
514
515/// Backoff growth shape (config spelling of `faucet_core::BackoffKind`).
516#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
517#[serde(rename_all = "snake_case")]
518pub enum BackoffSpec {
519    /// No delay between attempts.
520    None,
521    /// Constant `base_ms` delay.
522    Fixed,
523    /// `base_ms * 2^attempt`, capped at `max_ms`.
524    #[default]
525    Exponential,
526}
527
528/// Circuit-breaker tuning.
529#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
530#[serde(deny_unknown_fields)]
531pub struct CircuitBreakerSpec {
532    /// Consecutive exhausted-retry page failures before the circuit opens.
533    pub consecutive_failures: u32,
534    /// Re-entry cooldown in seconds (honored by the orchestration layer).
535    pub cooldown_secs: u64,
536}
537
538/// Poison-pill (per-row) policy.
539#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
540#[serde(deny_unknown_fields)]
541pub struct PoisonSpec {
542    /// Per-row write attempts before applying `action`.
543    pub max_row_attempts: u32,
544    /// Terminal action for a persistently failing row.
545    #[serde(default)]
546    pub action: PoisonActionSpec,
547}
548
549/// Terminal action for a poison row (config spelling of
550/// `faucet_core::PoisonAction`).
551#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
552#[serde(rename_all = "snake_case")]
553pub enum PoisonActionSpec {
554    /// Route to the DLQ (requires a `dlq:` block).
555    #[default]
556    Dlq,
557    /// Discard the row.
558    Drop,
559    /// Propagate the row error and abort the run.
560    Fail,
561}
562
563fn default_max_attempts() -> u32 {
564    5
565}
566fn default_base_ms() -> u64 {
567    200
568}
569fn default_max_ms() -> u64 {
570    30_000
571}
572
573impl ResilienceSpec {
574    /// Validate and build the core policy. Fail-fast on bad config.
575    pub fn to_policy(&self) -> Result<faucet_core::ResiliencePolicy, crate::error::CliError> {
576        use crate::error::CliError;
577        if self.retry.max_attempts < 1 {
578            return Err(CliError::Config(
579                "resilience.retry.max_attempts must be >= 1".into(),
580            ));
581        }
582        if self.retry.base_ms > self.retry.max_ms {
583            return Err(CliError::Config(
584                "resilience.retry.base_ms must be <= max_ms".into(),
585            ));
586        }
587        let retry_on = match &self.retry_on {
588            Some(v) if v.is_empty() => {
589                return Err(CliError::Config(
590                    "resilience.retry_on must not be empty".into(),
591                ));
592            }
593            Some(v) => faucet_core::RetryClassSet::from_iter(v.iter().copied()),
594            None => faucet_core::RetryClassSet::default(),
595        };
596        let backoff = match self.retry.backoff {
597            BackoffSpec::None => faucet_core::BackoffKind::None,
598            BackoffSpec::Fixed => faucet_core::BackoffKind::Fixed,
599            BackoffSpec::Exponential => faucet_core::BackoffKind::Exponential,
600        };
601        let circuit_breaker = match self.circuit_breaker {
602            Some(cb) if cb.consecutive_failures < 1 => {
603                return Err(CliError::Config(
604                    "resilience.circuit_breaker.consecutive_failures must be >= 1".into(),
605                ));
606            }
607            Some(cb) => Some(faucet_core::CircuitBreakerConfig {
608                consecutive_failures: cb.consecutive_failures,
609                cooldown: std::time::Duration::from_secs(cb.cooldown_secs),
610            }),
611            None => None,
612        };
613        let poison = match self.poison {
614            Some(p) if p.max_row_attempts < 1 => {
615                return Err(CliError::Config(
616                    "resilience.poison.max_row_attempts must be >= 1".into(),
617                ));
618            }
619            Some(p) => Some(faucet_core::PoisonPolicy {
620                max_row_attempts: p.max_row_attempts,
621                action: match p.action {
622                    PoisonActionSpec::Dlq => faucet_core::PoisonAction::Dlq,
623                    PoisonActionSpec::Drop => faucet_core::PoisonAction::Drop,
624                    PoisonActionSpec::Fail => faucet_core::PoisonAction::Fail,
625                },
626            }),
627            None => None,
628        };
629        Ok(faucet_core::ResiliencePolicy {
630            retry: faucet_core::RetryPolicy {
631                max_attempts: self.retry.max_attempts,
632                backoff,
633                base: std::time::Duration::from_millis(self.retry.base_ms),
634                max: std::time::Duration::from_millis(self.retry.max_ms),
635                jitter: self.retry.jitter,
636                retry_on,
637            },
638            circuit_breaker,
639            poison,
640        })
641    }
642}
643
644fn default_true() -> bool {
645    true
646}
647
648fn default_version() -> u32 {
649    1
650}
651fn default_parent_key() -> String {
652    "id".to_owned()
653}
654fn empty_object() -> Value {
655    Value::Object(Default::default())
656}
657
658fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
659where
660    D: serde::Deserializer<'de>,
661{
662    Option::<DlqSpec>::deserialize(deserializer).map(Some)
663}
664
665/// Resolve load-time `${env:}` / `${file:}` / `${secret:}` directives in a
666/// composed config document **after parsing** rather than on the raw text.
667///
668/// The document is parsed into an untyped value (by file extension), each string
669/// scalar is interpolated in place via [`interpolate_value`], and the tree is
670/// re-serialised back into the same format for the typed parse that follows.
671/// Resolving post-parse means a resolved value can never inject or break the
672/// document's structure (F43) — an env/file value containing `:`, a newline, or
673/// `-` stays the single scalar it was parsed as. Re-serialising and letting
674/// [`PipelineConfig::from_text`] re-parse keeps the typed-deserialise error
675/// messages (unknown field, type mismatch, version gate) identical to the old
676/// path. A syntax error in the document surfaces here, mapped exactly as
677/// `from_text` would map it.
678fn interpolate_document(text: &str, path: &Path) -> CliResult<String> {
679    use crate::interpolate::interpolate_value;
680    let ext = path
681        .extension()
682        .and_then(|e| e.to_str())
683        .map(str::to_ascii_lowercase);
684    match ext.as_deref() {
685        Some("yaml" | "yml") => {
686            let mut value: serde_json::Value =
687                serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
688                    path: path.to_path_buf(),
689                    message: friendly_parse_error(&e.to_string()),
690                })?;
691            interpolate_value(&mut value)?;
692            serde_yaml::to_string(&value).map_err(|e| CliError::ParseConfig {
693                path: path.to_path_buf(),
694                message: e.to_string(),
695            })
696        }
697        Some("json") => {
698            let mut value: serde_json::Value =
699                serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
700                    path: path.to_path_buf(),
701                    message: friendly_parse_error(&e.to_string()),
702                })?;
703            interpolate_value(&mut value)?;
704            serde_json::to_string(&value).map_err(|e| CliError::ParseConfig {
705                path: path.to_path_buf(),
706                message: e.to_string(),
707            })
708        }
709        _ => Err(CliError::UnknownExtension {
710            path: path.to_path_buf(),
711        }),
712    }
713}
714
715impl PipelineConfig {
716    /// Load a pipeline config from disk. The file extension determines the
717    /// parser: `.yaml` / `.yml` → YAML, `.json` → JSON. Other extensions are
718    /// rejected.
719    ///
720    /// Composition runs first via [`crate::compose::compose`]: `extends` (base
721    /// inheritance), `profiles` (the named overlay selected by `profile`), and
722    /// `!include` (YAML fragment substitution) are resolved into a single
723    /// merged document before `${...}` interpolation and parsing.
724    ///
725    /// Secret directives (`${vault:…}`, `${aws-sm:…}`, etc.) are **not**
726    /// resolved by this path. If any are present the call returns
727    /// `CliError::SecretsRequireAsyncLoad` — use [`Self::from_path_async`] instead.
728    pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
729        let path = path.as_ref();
730        let composed = crate::compose::compose(path, profile)?;
731        let interpolated = interpolate_document(&composed, path)?;
732        let cfg = Self::from_text(&interpolated, path)?;
733        // Secret directives need the async resolver path; never let them survive
734        // into a connector config as literal `${vault:…}` text.
735        crate::secrets::ensure_no_secret_directives(&cfg)?;
736        Ok(cfg)
737    }
738
739    /// Like [`Self::from_path`] but does not reject secret directives — they are
740    /// left unresolved. Used by `validate --no-secrets`. Composition
741    /// (extends/profiles/`!include`) runs first via [`crate::compose::compose`].
742    pub fn from_path_tolerating_secrets(
743        path: impl AsRef<Path>,
744        profile: Option<&str>,
745    ) -> CliResult<Self> {
746        let path = path.as_ref();
747        let composed = crate::compose::compose(path, profile)?;
748        let interpolated = interpolate_document(&composed, path)?;
749        Self::from_text(&interpolated, path)
750    }
751
752    /// Async load path: like [`Self::from_path`] but resolves secret-manager
753    /// directives (`${vault:…}`, `${aws-sm:…}`, …) as a final stage. Composition
754    /// (extends/profiles/`!include`) runs first via [`crate::compose::compose`].
755    pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
756        let path = path.as_ref();
757        let composed = crate::compose::compose(path, profile)?;
758        let interpolated = interpolate_document(&composed, path)?;
759        let mut cfg = Self::from_text(&interpolated, path)?;
760        crate::secrets::resolve_secrets(&mut cfg).await?;
761        Ok(cfg)
762    }
763
764    /// Parse an already-interpolated config string. `path` is only used for
765    /// error messages and to pick the parser by file extension.
766    pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
767        let ext = path
768            .extension()
769            .and_then(|e| e.to_str())
770            .map(str::to_ascii_lowercase);
771        let cfg: PipelineConfig = match ext.as_deref() {
772            Some("yaml" | "yml") => {
773                serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
774                    path: path.to_path_buf(),
775                    message: friendly_parse_error(&e.to_string()),
776                })?
777            }
778            Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
779                path: path.to_path_buf(),
780                message: friendly_parse_error(&e.to_string()),
781            })?,
782            _ => {
783                return Err(CliError::UnknownExtension {
784                    path: path.to_path_buf(),
785                });
786            }
787        };
788        Self::finish(cfg, path)
789    }
790
791    /// Build a config from an already-parsed JSON value (used by `faucet serve`,
792    /// which merges a submitted body onto a `--default-config` base). Runs the
793    /// same version check + structural `${...}` ref resolution as [`Self::from_text`].
794    ///
795    /// **Note:** load-time `${env:VAR}` / `${file:PATH}` / `${secret:VAR}` directives
796    /// are **not** resolved here — the caller must pre-resolve them (e.g. by running
797    /// `interpolate` on the source text) before building the `Value`.
798    pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
799        let synthetic = Path::new("<submitted>");
800        let cfg: PipelineConfig =
801            serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
802                path: synthetic.to_path_buf(),
803                message: friendly_parse_error(&e.to_string()),
804            })?;
805        Self::finish(cfg, synthetic)
806    }
807
808    /// Shared post-parse tail: version gate + structural `${...}` ref resolution.
809    fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
810        if cfg.version != 1 {
811            return Err(CliError::ParseConfig {
812                path: path.to_path_buf(),
813                message: format!(
814                    "unsupported pipeline version {}, only version 1 is recognised",
815                    cfg.version
816                ),
817            });
818        }
819        crate::interpolate::resolve_config_refs(&mut cfg)?;
820        if let Some(obs) = cfg.observability.as_ref()
821            && let Some(otel) = obs.otel.as_ref()
822        {
823            otel.to_core().map_err(CliError::Config)?;
824        }
825        Ok(cfg)
826    }
827}
828
829/// Translate the typical serde "missing field" message into a hint when the
830/// caller appears to be using the pre-#54 top-level shape.
831fn friendly_parse_error(raw: &str) -> String {
832    let lower = raw.to_ascii_lowercase();
833    if lower.contains("missing field `pipeline`") {
834        return format!(
835            "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
836        );
837    }
838    if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
839        return format!(
840            "{raw}\n\nhint: config composition (`extends` / `profiles` / `!include`) is resolved only for file-based loads, not for configs submitted to `faucet serve` — resolve composition before submitting."
841        );
842    }
843    raw.to_owned()
844}
845
846/// Convenience: parse a config from text using a synthetic path so the right
847/// parser is selected. Used by tests and the `validate --stdin` flow.
848pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
849    let synthetic = PathBuf::from(format!("pipeline.{ext}"));
850    PipelineConfig::from_text(text, &synthetic)
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use serde_json::json;
857
858    #[test]
859    fn parses_minimal_pipeline_yaml() {
860        let yaml = r#"
861version: 1
862pipeline:
863  source:
864    type: rest
865    config:
866      base_url: https://api.example.com
867  sink:
868    type: jsonl
869    config:
870      path: ./out.jsonl
871"#;
872        let cfg = parse_with_extension(yaml, "yaml").unwrap();
873        assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
874        assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
875        assert!(cfg.matrix.is_empty());
876        assert!(cfg.execution.is_none());
877        assert!(cfg.pipeline.transforms.is_empty());
878        assert!(cfg.pipeline.state.is_none());
879    }
880
881    #[test]
882    fn parses_replication_block() {
883        let yaml = r#"
884version: 1
885pipeline:
886  source: { type: postgres-cdc, config: { connection_url: "postgres://x", slot_name: s, publication_name: p } }
887  sink:   { type: postgres, config: { connection_url: "postgres://y", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
888  state:  { type: file, config: { path: ./st } }
889replication:
890  mode: snapshot_then_cdc
891  snapshot:
892    source: { type: postgres, config: { connection_url: "postgres://x", query: "SELECT * FROM t" } }
893"#;
894        let cfg = parse_with_extension(yaml, "yaml").unwrap();
895        let r = cfg.replication.expect("replication parsed");
896        assert_eq!(r.snapshot.source.kind, "postgres");
897    }
898
899    #[test]
900    fn pipeline_spec_parses_schema_block() {
901        let yaml = r#"
902version: 1
903pipeline:
904  source:
905    type: rest
906    config:
907      base_url: https://api.example.com
908  sink:
909    type: jsonl
910    config:
911      path: ./out.jsonl
912  schema:
913    on_drift: evolve
914    allow_type_widening: false
915"#;
916        let cfg = parse_with_extension(yaml, "yaml").unwrap();
917        let schema = cfg.pipeline.schema.expect("schema block parsed");
918        assert_eq!(schema.on_drift, faucet_core::OnDrift::Evolve);
919        assert!(!schema.allow_type_widening);
920    }
921
922    #[test]
923    fn parses_minimal_json() {
924        let raw = r#"{
925            "version": 1,
926            "pipeline": {
927                "source": {"type": "rest", "config": {}},
928                "sink":   {"type": "jsonl", "config": {"path": "./out.jsonl"}}
929            }
930        }"#;
931        let cfg = parse_with_extension(raw, "json").unwrap();
932        assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
933    }
934
935    #[test]
936    fn parses_matrix_rows_with_partial_overrides() {
937        let yaml = r#"
938version: 1
939pipeline:
940  source: { type: rest, config: { base_url: https://api.example.com } }
941  sink:   { type: jsonl, config: { path: ./out.jsonl } }
942matrix:
943  - id: users
944    source: { config: { path: /v1/users } }
945    sink:   { config: { path: ./users.jsonl } }
946  - id: posts
947    parent: users
948    parent_key: user_id
949    source: { config: { path: "/v1/users/${users.id}/posts" } }
950"#;
951        let cfg = parse_with_extension(yaml, "yaml").unwrap();
952        assert_eq!(cfg.matrix.len(), 2);
953        assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
954        assert!(cfg.matrix[0].parent.is_none());
955        let users_src = cfg.matrix[0].source.as_ref().unwrap();
956        assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
957
958        assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
959        assert_eq!(cfg.matrix[1].parent_key, "user_id");
960    }
961
962    #[test]
963    fn parent_key_defaults_to_id() {
964        let yaml = r#"
965version: 1
966pipeline:
967  source: { type: rest, config: {} }
968  sink:   { type: jsonl, config: { path: ./o.jsonl } }
969matrix:
970  - { id: users }
971  - { id: posts, parent: users }
972"#;
973        let cfg = parse_with_extension(yaml, "yaml").unwrap();
974        assert_eq!(cfg.matrix[1].parent_key, "id");
975    }
976
977    #[test]
978    fn parses_execution_block() {
979        let yaml = r#"
980version: 1
981pipeline:
982  source: { type: rest, config: {} }
983  sink:   { type: jsonl, config: { path: ./o.jsonl } }
984execution:
985  max_concurrent: 8
986  on_error: stop
987"#;
988        let cfg = parse_with_extension(yaml, "yaml").unwrap();
989        let exec = cfg.execution.unwrap();
990        assert_eq!(exec.max_concurrent, Some(8));
991        assert_eq!(exec.on_error, OnError::Stop);
992    }
993
994    #[test]
995    fn on_error_defaults_to_continue() {
996        let yaml = r#"
997version: 1
998pipeline:
999  source: { type: rest, config: {} }
1000  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1001execution: { max_concurrent: 2 }
1002"#;
1003        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1004        assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
1005    }
1006
1007    #[test]
1008    fn rejects_old_top_level_source_sink_with_hint() {
1009        // Pre-#54 shape: `source:` and `sink:` at the top level.
1010        let yaml = r#"
1011version: 1
1012source: { type: rest, config: {} }
1013sink:   { type: jsonl, config: { path: ./o.jsonl } }
1014"#;
1015        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1016        let msg = err.to_string();
1017        assert!(
1018            msg.contains("pipeline"),
1019            "expected a hint about wrapping in `pipeline:`, got: {msg}"
1020        );
1021    }
1022
1023    #[test]
1024    fn rejects_unknown_extension() {
1025        let text = "version: 1\n";
1026        let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
1027        assert!(matches!(err, CliError::UnknownExtension { .. }));
1028    }
1029
1030    #[test]
1031    fn rejects_future_version() {
1032        let yaml = r#"
1033version: 99
1034pipeline:
1035  source: { type: rest, config: {} }
1036  sink:   { type: jsonl, config: { path: ./x } }
1037"#;
1038        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1039        match err {
1040            CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1041            other => panic!("expected ParseConfig, got {other:?}"),
1042        }
1043    }
1044
1045    #[test]
1046    fn transforms_and_state_round_trip() {
1047        let yaml = r#"
1048version: 1
1049pipeline:
1050  source:
1051    type: rest
1052    config: {}
1053  transforms:
1054    - type: snake_case
1055    - type: flatten
1056      config: { separator: "__" }
1057  sink:
1058    type: jsonl
1059    config: { path: "./out.jsonl" }
1060  state:
1061    type: file
1062    config: { path: "./.faucet-state" }
1063"#;
1064        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1065        assert_eq!(cfg.pipeline.transforms.len(), 2);
1066        assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
1067        assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
1068        assert_eq!(
1069            cfg.pipeline.transforms[1].config,
1070            json!({"separator": "__"})
1071        );
1072        let state = cfg.pipeline.state.unwrap();
1073        assert_eq!(state.kind, "file");
1074    }
1075
1076    #[test]
1077    fn from_path_interpolates_env_var() {
1078        unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
1079        let dir = tempfile::tempdir().unwrap();
1080        let path = dir.path().join("pipeline.yaml");
1081        std::fs::write(
1082            &path,
1083            r#"
1084version: 1
1085pipeline:
1086  source:
1087    type: rest
1088    config:
1089      base_url: ${env:FAUCET_CFG_URL}
1090  sink:
1091    type: jsonl
1092    config:
1093      path: ./out.jsonl
1094"#,
1095        )
1096        .unwrap();
1097        let cfg = PipelineConfig::from_path(&path, None).unwrap();
1098        assert_eq!(
1099            cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1100            "https://x.example"
1101        );
1102        unsafe { std::env::remove_var("FAUCET_CFG_URL") };
1103    }
1104
1105    #[test]
1106    fn observability_block_parses() {
1107        let y = r#"
1108version: 1
1109name: x
1110observability:
1111  prometheus:
1112    listen: "127.0.0.1:9464"
1113    buckets: [0.01, 0.1, 1.0]
1114  tracing:
1115    level: "info"
1116pipeline:
1117  source:
1118    type: rest
1119    config:
1120      base_url: "https://example.com"
1121      path: "/data"
1122  sink:
1123    type: jsonl
1124    config:
1125      path: "/tmp/faucet-test.jsonl"
1126"#;
1127        let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
1128        let obs = cfg.observability.expect("observability block parsed");
1129        let p = obs.prometheus.expect("prometheus parsed");
1130        assert_eq!(p.listen, "127.0.0.1:9464");
1131        assert_eq!(p.buckets.unwrap().len(), 3);
1132        assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
1133    }
1134
1135    #[test]
1136    fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
1137        // `${users.id}` must survive load-time interpolation so the matrix
1138        // expander / record-time resolver can handle it later.
1139        let dir = tempfile::tempdir().unwrap();
1140        let path = dir.path().join("pipeline.yaml");
1141        std::fs::write(
1142            &path,
1143            r#"
1144version: 1
1145pipeline:
1146  source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1147  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1148"#,
1149        )
1150        .unwrap();
1151        let cfg = PipelineConfig::from_path(&path, None).unwrap();
1152        assert_eq!(
1153            cfg.pipeline.source.as_ref().unwrap().config["path"],
1154            "/v1/users/${users.id}/posts"
1155        );
1156    }
1157
1158    #[cfg(feature = "schedule")]
1159    #[test]
1160    fn parses_schedule_block() {
1161        let yaml = r#"
1162version: 1
1163schedule:
1164  cron: "0 2 * * *"
1165  timezone: "America/Los_Angeles"
1166  overlap_policy: skip
1167  max_consecutive_failures: 5
1168pipeline:
1169  source: { type: rest, config: {} }
1170  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1171"#;
1172        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1173        let s = cfg.schedule.expect("schedule parsed");
1174        assert_eq!(s.cron, "0 2 * * *");
1175        assert_eq!(s.timezone, "America/Los_Angeles");
1176        assert_eq!(s.max_consecutive_failures, Some(5));
1177    }
1178
1179    #[test]
1180    fn execution_spec_parses_adaptive_block() {
1181        let yaml = r#"
1182version: 1
1183pipeline:
1184  source: { type: rest, config: { base_url: https://api.example.com } }
1185  sink:   { type: jsonl, config: { path: ./out.jsonl } }
1186execution:
1187  adaptive_batch_size:
1188    enabled: true
1189    min: 200
1190    max: 4000
1191    target_latency_ms: 800
1192"#;
1193        let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
1194        let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
1195        assert!(ab.enabled);
1196        assert_eq!(ab.min, 200);
1197        assert_eq!(ab.target_latency_ms, Some(800));
1198        ab.validate().unwrap();
1199    }
1200
1201    #[cfg(feature = "quality")]
1202    #[test]
1203    fn parses_quality_block() {
1204        let yaml = r#"
1205version: 1
1206pipeline:
1207  source: { type: rest, config: { url: "https://x" } }
1208  quality:
1209    record:
1210      - { type: not_null, field: id, on_failure: abort }
1211  sink: { type: stdout, config: {} }
1212"#;
1213        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1214        let q = cfg.pipeline.quality.expect("quality parsed");
1215        assert_eq!(q.record.len(), 1);
1216    }
1217
1218    #[test]
1219    fn parses_dlq_block_with_defaults() {
1220        let yaml = r#"
1221version: 1
1222pipeline:
1223  source: { type: rest, config: {} }
1224  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1225  dlq:
1226    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1227"#;
1228        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1229        let dlq = cfg.pipeline.dlq.expect("dlq parsed");
1230        assert_eq!(dlq.sink.kind, "jsonl");
1231        assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
1232        assert!(dlq.max_failures_per_page.is_none());
1233        assert!(dlq.max_failures_total.is_none());
1234        assert!(dlq.include_original_payload);
1235    }
1236
1237    #[test]
1238    fn parses_dlq_block_with_dlq_all_and_budgets() {
1239        let yaml = r#"
1240version: 1
1241pipeline:
1242  source: { type: rest, config: {} }
1243  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1244  dlq:
1245    sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
1246    on_batch_error: dlq_all
1247    max_failures_per_page: 100
1248    max_failures_total: 10000
1249"#;
1250        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1251        let dlq = cfg.pipeline.dlq.unwrap();
1252        assert_eq!(dlq.sink.kind, "kafka");
1253        assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1254        assert_eq!(dlq.max_failures_per_page, Some(100));
1255        assert_eq!(dlq.max_failures_total, Some(10000));
1256    }
1257
1258    #[test]
1259    fn matrix_row_dlq_null_disables_inherited_dlq() {
1260        let yaml = r#"
1261version: 1
1262pipeline:
1263  source: { type: rest, config: {} }
1264  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1265  dlq:
1266    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1267matrix:
1268  - id: a
1269  - id: b
1270    dlq: null
1271"#;
1272        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1273        assert!(cfg.matrix[0].dlq.is_none());
1274        assert_eq!(cfg.matrix[1].dlq, Some(None));
1275    }
1276
1277    #[test]
1278    fn matrix_row_dlq_object_replaces_inherited_dlq() {
1279        let yaml = r#"
1280version: 1
1281pipeline:
1282  source: { type: rest, config: {} }
1283  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1284  dlq:
1285    sink: { type: jsonl, config: { path: ./base.jsonl } }
1286matrix:
1287  - id: a
1288    dlq:
1289      sink: { type: jsonl, config: { path: ./a.jsonl } }
1290      on_batch_error: dlq_all
1291"#;
1292        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1293        let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
1294        assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
1295        let sink_path = row_dlq.sink.config.get("path").unwrap();
1296        assert_eq!(sink_path, "./a.jsonl");
1297    }
1298
1299    #[test]
1300    fn parses_named_sources_and_sinks() {
1301        let yaml = r#"
1302version: 1
1303pipeline:
1304  sources:
1305    users_api:
1306      type: rest
1307      config: { base_url: https://api.example.com }
1308    posts_api:
1309      type: rest
1310      config: { base_url: https://api.example.com }
1311  sinks:
1312    warehouse:
1313      type: postgres
1314      config: { connection_url: "postgres://x" }
1315"#;
1316        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1317        assert!(cfg.pipeline.source.is_none());
1318        assert!(cfg.pipeline.sink.is_none());
1319        assert_eq!(cfg.pipeline.sources.len(), 2);
1320        assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
1321        assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
1322    }
1323
1324    #[test]
1325    fn legacy_singular_source_still_parses() {
1326        let yaml = r#"
1327version: 1
1328pipeline:
1329  source: { type: rest, config: {} }
1330  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1331"#;
1332        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1333        assert!(cfg.pipeline.source.is_some());
1334        assert!(cfg.pipeline.sink.is_some());
1335        assert!(cfg.pipeline.sources.is_empty());
1336        assert!(cfg.pipeline.sinks.is_empty());
1337    }
1338
1339    #[test]
1340    fn parses_matrix_row_with_ref_field() {
1341        let yaml = r#"
1342version: 1
1343pipeline:
1344  source: { type: rest, config: {} }
1345  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1346matrix:
1347  - id: load_users
1348    source:
1349      ref: users_api
1350      config: { path: /v1/users }
1351"#;
1352        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1353        let src = cfg.matrix[0].source.as_ref().unwrap();
1354        assert_eq!(src.r#ref.as_deref(), Some("users_api"));
1355        assert_eq!(src.kind, None);
1356        assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
1357    }
1358
1359    #[test]
1360    fn parses_top_level_vars_block() {
1361        let yaml = r#"
1362version: 1
1363vars:
1364  api_base: https://api.example.com
1365  api_token_env: API_TOKEN
1366pipeline:
1367  source: { type: rest, config: {} }
1368  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1369"#;
1370        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1371        let vars = cfg.vars.as_ref().unwrap();
1372        assert_eq!(vars["api_base"], "https://api.example.com");
1373        assert_eq!(vars["api_token_env"], "API_TOKEN");
1374    }
1375
1376    #[test]
1377    fn vars_block_is_optional() {
1378        let yaml = r#"
1379version: 1
1380pipeline:
1381  source: { type: rest, config: {} }
1382  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1383"#;
1384        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1385        assert!(cfg.vars.is_none());
1386    }
1387
1388    #[test]
1389    fn from_path_resolves_vars_at_load() {
1390        let dir = tempfile::tempdir().unwrap();
1391        let path = dir.path().join("pipeline.yaml");
1392        std::fs::write(
1393            &path,
1394            r#"
1395version: 1
1396vars:
1397  base: https://api.example.com
1398pipeline:
1399  source: { type: rest, config: { url: "${vars.base}/v1" } }
1400  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1401"#,
1402        )
1403        .unwrap();
1404        let cfg = PipelineConfig::from_path(&path, None).unwrap();
1405        assert_eq!(
1406            cfg.pipeline.source.as_ref().unwrap().config["url"],
1407            "https://api.example.com/v1"
1408        );
1409    }
1410
1411    #[test]
1412    fn sync_from_path_errors_on_secret_directive() {
1413        let dir = tempfile::tempdir().unwrap();
1414        let path = dir.path().join("p.yaml");
1415        std::fs::write(
1416            &path,
1417            r#"
1418version: 1
1419pipeline:
1420  source: { type: rest, config: { url: "${vault:secret/x}" } }
1421  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1422"#,
1423        )
1424        .unwrap();
1425        match PipelineConfig::from_path(&path, None).unwrap_err() {
1426            CliError::SecretsRequireAsyncLoad => {}
1427            other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1428        }
1429    }
1430
1431    #[test]
1432    fn from_value_accepts_v1_and_resolves_refs() {
1433        let v = serde_json::json!({
1434            "version": 1,
1435            "vars": { "out": "resolved.jsonl" },
1436            "pipeline": {
1437                "source": { "type": "csv",  "config": { "path": "x.csv" } },
1438                "sink":   { "type": "jsonl", "config": { "path": "${vars.out}" } }
1439            }
1440        });
1441        let cfg = PipelineConfig::from_value(v).unwrap();
1442        assert_eq!(cfg.version, 1);
1443        // structural ${vars.*} refs are resolved by from_value (via finish → resolve_config_refs)
1444        assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1445    }
1446
1447    #[test]
1448    fn from_value_rejects_non_v1() {
1449        // structurally valid config; only the version is wrong
1450        let v = serde_json::json!({ "version": 99, "pipeline": {} });
1451        let err = PipelineConfig::from_value(v).unwrap_err();
1452        match err {
1453            CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1454            other => panic!("expected ParseConfig, got {other:?}"),
1455        }
1456    }
1457
1458    #[tokio::test]
1459    async fn async_from_path_loads_without_secrets() {
1460        let dir = tempfile::tempdir().unwrap();
1461        let path = dir.path().join("p.yaml");
1462        std::fs::write(
1463            &path,
1464            r#"
1465version: 1
1466pipeline:
1467  source: { type: rest, config: { base_url: https://x } }
1468  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1469"#,
1470        )
1471        .unwrap();
1472        let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1473        assert_eq!(cfg.version, 1);
1474    }
1475
1476    #[cfg(feature = "lineage")]
1477    #[test]
1478    fn parses_lineage_block() {
1479        let yaml = r#"
1480version: 1
1481lineage:
1482  namespace: prod
1483  transport: { type: file, config: { path: /tmp/ol.jsonl } }
1484pipeline:
1485  source: { type: rest, config: {} }
1486  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1487"#;
1488        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1489        let l = cfg.lineage.expect("lineage parsed");
1490        assert_eq!(l.namespace, "prod");
1491    }
1492
1493    #[test]
1494    fn from_path_resolves_extends_and_profile() {
1495        let dir = tempfile::tempdir().unwrap();
1496        std::fs::write(
1497            dir.path().join("base.yaml"),
1498            "version: 1\npipeline:\n  source: { type: csv, config: { path: x.csv } }\n  sink: { type: jsonl, config: { path: base.jsonl } }\nprofiles:\n  prod:\n    pipeline:\n      sink: { config: { path: prod.jsonl } }\n",
1499        )
1500        .unwrap();
1501        let app = dir.path().join("app.yaml");
1502        std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1503
1504        // No profile → base sink path.
1505        let cfg = PipelineConfig::from_path(&app, None).unwrap();
1506        assert_eq!(
1507            cfg.pipeline.sink.as_ref().unwrap().config["path"],
1508            "base.jsonl"
1509        );
1510
1511        // --profile prod → overridden sink path.
1512        let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1513        assert_eq!(
1514            cfg.pipeline.sink.as_ref().unwrap().config["path"],
1515            "prod.jsonl"
1516        );
1517    }
1518
1519    #[test]
1520    fn from_value_rejects_extends_with_composition_hint() {
1521        // A submitted body (serve path) must not silently accept `extends`.
1522        let v = serde_json::json!({
1523            "version": 1,
1524            "extends": "base.yaml",
1525            "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1526        });
1527        let err = PipelineConfig::from_value(v).unwrap_err();
1528        let msg = err.to_string();
1529        assert!(
1530            msg.contains("composition"),
1531            "expected composition hint, got: {msg}"
1532        );
1533    }
1534
1535    #[test]
1536    fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1537        // Default when omitted: top-level delivery should be AtLeastOnce.
1538        let yaml = r#"
1539version: 1
1540pipeline:
1541  source: { type: rest, config: {} }
1542  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1543"#;
1544        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1545        assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1546
1547        // Explicit exactly_once at top level.
1548        let yaml2 = r#"
1549version: 1
1550delivery: exactly_once
1551pipeline:
1552  source: { type: rest, config: {} }
1553  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1554"#;
1555        let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
1556        assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
1557
1558        // Matrix row: absent delivery inherits (None), explicit overrides.
1559        let yaml3 = r#"
1560version: 1
1561delivery: at_least_once
1562pipeline:
1563  source: { type: rest, config: {} }
1564  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1565matrix:
1566  - id: a
1567  - id: b
1568    delivery: exactly_once
1569"#;
1570        let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
1571        assert_eq!(cfg3.matrix[0].delivery, None);
1572        assert_eq!(
1573            cfg3.matrix[1].delivery,
1574            Some(faucet_core::DeliveryMode::ExactlyOnce)
1575        );
1576    }
1577
1578    #[test]
1579    fn resilience_spec_parses_and_builds_policy() {
1580        let yaml = r#"
1581version: 1
1582pipeline:
1583  source: { type: rest, config: { base_url: "https://x" } }
1584  sink: { type: stdout, config: {} }
1585resilience:
1586  retry: { max_attempts: 4, backoff: exponential, base_ms: 100, max_ms: 5000, jitter: true }
1587  retry_on: [http_5xx, timeout]
1588  circuit_breaker: { consecutive_failures: 3, cooldown_secs: 30 }
1589  poison: { max_row_attempts: 2, action: dlq }
1590"#;
1591        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1592        let spec = cfg.resilience.unwrap();
1593        let policy = spec.to_policy().unwrap();
1594        assert_eq!(policy.retry.max_attempts, 4);
1595        assert_eq!(policy.circuit_breaker.unwrap().consecutive_failures, 3);
1596        assert_eq!(policy.poison.unwrap().max_row_attempts, 2);
1597    }
1598
1599    #[test]
1600    fn resilience_rejects_zero_max_attempts() {
1601        let yaml = r#"
1602version: 1
1603pipeline:
1604  source: { type: rest, config: { base_url: "https://x" } }
1605  sink: { type: stdout, config: {} }
1606resilience: { retry: { max_attempts: 0 } }
1607"#;
1608        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1609        let err = cfg.resilience.unwrap().to_policy().unwrap_err();
1610        assert!(err.to_string().contains("max_attempts"));
1611    }
1612
1613    #[test]
1614    fn observability_parses_otel_block() {
1615        let yaml = r#"
1616version: 1
1617pipeline:
1618  source: { type: rest, config: { base_url: "http://x" } }
1619  sink: { type: stdout, config: {} }
1620observability:
1621  otel:
1622    endpoint: http://collector:4317
1623    protocol: grpc
1624    export: [traces, metrics]
1625"#;
1626        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1627        let otel = cfg.observability.unwrap().otel.unwrap();
1628        assert_eq!(otel.endpoint, "http://collector:4317");
1629    }
1630
1631    #[test]
1632    fn otel_validation_rejects_bad_ratio() {
1633        let yaml = r#"
1634version: 1
1635pipeline:
1636  source: { type: rest, config: { base_url: "http://x" } }
1637  sink: { type: stdout, config: {} }
1638observability:
1639  otel:
1640    sample_ratio: 9.0
1641"#;
1642        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1643        assert!(format!("{err}").contains("sample_ratio"));
1644    }
1645}