Skip to main content

faucet_cli/
config.rs

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