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