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