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