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