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