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