Skip to main content

faucet_cli/
config.rs

1//! Parsed `pipeline.yaml` / `pipeline.json` schema (matrix-aware).
2//!
3//! Top-level shape:
4//!
5//! ```yaml
6//! version: 1
7//! name: optional-human-name
8//! pipeline:               # required — full base config
9//!   source: { type, config }
10//!   sink:   { type, config }
11//!   transforms: [...]
12//!   state:  { type, config }
13//! matrix:                 # optional — omitted or empty == one anonymous row
14//!   - id: <string>
15//!     parent: <id>
16//!     parent_key: <jsonpath>   # default "id"
17//!     source: { ... }     # partial override, deep-merged into pipeline.source
18//!     sink:   { ... }
19//!     transforms: [...]   # row-level transforms, appended after pipeline + source layers
20//!     state:  { ... }     # if Some, replaces pipeline.state wholesale
21//! execution:              # optional
22//!   max_concurrent: <usize>
23//!   on_error: continue|stop
24//! ```
25//!
26//! The wire format is intentionally loose: every connector keeps its own
27//! config schema, and the CLI threads a `serde_json::Value` through to the
28//! connector's `serde::Deserialize` impl. That keeps this struct stable as
29//! new fields are added to individual connectors without needing CLI work.
30
31use crate::error::{CliError, CliResult};
32use crate::interpolate::interpolate;
33use schemars::JsonSchema;
34use serde::{Deserialize, Serialize};
35use serde_json::Value;
36use std::collections::HashMap;
37use std::path::{Path, PathBuf};
38
39/// Top-level pipeline definition.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct PipelineConfig {
43    /// Config-format version. Currently always `1`.
44    #[serde(default = "default_version")]
45    pub version: u32,
46
47    /// Optional human-readable name (used in logs and error messages).
48    #[serde(default)]
49    pub name: Option<String>,
50
51    /// Optional shared constants. Resolvable as `${vars.key}` anywhere in
52    /// the config (including inside named templates). Resolved at load time,
53    /// after env/file/secret substitution.
54    #[serde(default)]
55    pub vars: Option<HashMap<String, Value>>,
56
57    /// Optional named auth providers. Each entry is a `{ type, config }` spec
58    /// (the same shape as inline auth) built once and shared across every
59    /// connector that references it via `auth: { ref: <name> }`. Values are kept
60    /// as raw JSON so `faucet-auth` owns the per-type schema.
61    #[serde(default)]
62    pub auth: Option<HashMap<String, Value>>,
63
64    /// Base pipeline — every matrix row is deep-merged into this.
65    pub pipeline: PipelineSpec,
66
67    /// Matrix of per-row overrides. Empty or omitted means "one anonymous row"
68    /// (full pipeline runs once with no merge).
69    #[serde(default)]
70    pub matrix: Vec<MatrixRow>,
71
72    /// Optional execution controls (concurrency, on-error policy).
73    #[serde(default)]
74    pub execution: Option<ExecutionSpec>,
75
76    /// Optional observability configuration (Prometheus + tracing).
77    #[serde(default)]
78    pub observability: Option<ObservabilitySpec>,
79
80    /// Delivery guarantee for every row (overridable per matrix row). Default
81    /// `at_least_once` — no behaviour change for existing configs. `exactly_once`
82    /// requires an idempotent sink, a deterministic-replay source, and a state
83    /// store (enforced at expand time).
84    #[serde(default)]
85    pub delivery: faucet_core::DeliveryMode,
86
87    /// Optional cron schedule. Only consumed by `faucet schedule`; ignored by
88    /// `faucet run`. Presence makes the config runnable on a schedule.
89    #[cfg(feature = "schedule")]
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub schedule: Option<crate::schedule::spec::ScheduleSpec>,
92
93    /// Optional OpenLineage emission. Consumed by `faucet run`/`schedule`/`serve`.
94    #[cfg(feature = "lineage")]
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub lineage: Option<faucet_lineage::LineageConfig>,
97}
98
99/// The base pipeline definition. Each matrix row is resolved against the
100/// template catalogs below; the singular `source` / `sink` fields are the
101/// legacy way to declare a single template (internally named `default`).
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct PipelineSpec {
105    /// Legacy singular source — registers as a template named `default`.
106    /// Defining both `source` and `sources.default` is an error at expand time.
107    #[serde(default)]
108    pub source: Option<ConnectorSpec>,
109
110    /// Legacy singular sink — registers as a template named `default`.
111    #[serde(default)]
112    pub sink: Option<ConnectorSpec>,
113
114    /// Named source templates. A matrix row picks one via `source.ref: NAME`.
115    #[serde(default)]
116    pub sources: HashMap<String, ConnectorSpec>,
117
118    /// Named sink templates. A matrix row picks one via `sink.ref: NAME`.
119    #[serde(default)]
120    pub sinks: HashMap<String, ConnectorSpec>,
121
122    #[serde(default)]
123    pub transforms: Vec<TransformSpec>,
124    #[serde(default)]
125    pub state: Option<StateStoreSpec>,
126    #[serde(default)]
127    pub dlq: Option<DlqSpec>,
128
129    /// Data-quality checks (pipeline-level; no matrix-row override in v1).
130    #[cfg(feature = "quality")]
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub quality: Option<faucet_core::QualitySpec>,
133}
134
135/// A `{ type, config }` block, the universal shape for both sources and sinks.
136///
137/// Source templates may additionally carry `transforms:` and
138/// `inherit_transforms:`. Both are rejected on sink templates at expand time.
139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
140#[serde(deny_unknown_fields)]
141pub struct ConnectorSpec {
142    /// Connector type — matches the suffix of the underlying crate
143    /// (e.g. `rest` for `faucet-source-rest`).
144    #[serde(rename = "type")]
145    pub kind: String,
146
147    /// Connector-specific config object. Passed through verbatim to the
148    /// connector's `serde::Deserialize` impl.
149    #[serde(default = "empty_object")]
150    pub config: Value,
151
152    /// Transforms bound to this source template. Applied after `T_pipeline`
153    /// and before `T_row` for every matrix row that resolves to this template.
154    /// Rejected at expand time when this `ConnectorSpec` is used as a sink.
155    #[serde(default)]
156    pub transforms: Option<Vec<TransformSpec>>,
157
158    /// When `false`, drops upstream `T_pipeline` transforms for every matrix
159    /// row that resolves to this source template. Default `true`. Rejected
160    /// at expand time on sinks.
161    #[serde(default = "default_true")]
162    pub inherit_transforms: bool,
163}
164
165/// A partial connector override carried by a matrix row. Both `type` and
166/// `config` are optional so rows can swap the kind, override only the inner
167/// config, or both. `ref:` (optional) picks which named template under
168/// `pipeline.sources` / `pipeline.sinks` this row instantiates; when absent,
169/// the row inherits the legacy singular `pipeline.source` / `pipeline.sink`
170/// (registered internally as a template named `default`).
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(deny_unknown_fields)]
173pub struct PartialConnector {
174    /// Name of the template under `pipeline.sources` / `pipeline.sinks` to
175    /// instantiate. `None` falls back to the `default` template.
176    #[serde(default)]
177    pub r#ref: Option<String>,
178    /// Override the connector kind (otherwise inherits from the template).
179    #[serde(rename = "type", default)]
180    pub kind: Option<String>,
181    /// Partial config object — deep-merged into the resolved template's config.
182    #[serde(default)]
183    pub config: Option<Value>,
184}
185
186/// A single transform declaration.
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
188#[serde(deny_unknown_fields)]
189pub struct TransformSpec {
190    /// Built-in transform identifier. One of: `flatten`, `rename_keys`,
191    /// `snake_case`, `select`, `drop`, `set`, `rename_field`, `cast`, `redact`,
192    /// `value_case`. See the docs-site cookbook page on transforms for
193    /// per-transform config schemas.
194    #[serde(rename = "type")]
195    pub kind: String,
196
197    /// Transform-specific config object (e.g. `{ separator: "__" }` for flatten).
198    #[serde(default = "empty_object")]
199    pub config: Value,
200}
201
202/// State-store backend selector.
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(deny_unknown_fields)]
205pub struct StateStoreSpec {
206    /// Store type: `file`, `memory`, `redis`, or `postgres`.
207    #[serde(rename = "type")]
208    pub kind: String,
209
210    /// Store-specific config.
211    #[serde(default = "empty_object")]
212    pub config: Value,
213}
214
215/// One row of the `matrix:` block.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(deny_unknown_fields)]
218pub struct MatrixRow {
219    /// Row identifier. Required for parent/child references and runtime
220    /// `${id.path}` interpolation. Anonymous rows get a synthetic `row-N` id.
221    #[serde(default)]
222    pub id: Option<String>,
223
224    /// If set, this row runs once per record produced by the named parent row.
225    #[serde(default)]
226    pub parent: Option<String>,
227
228    /// Dotted field path inside each parent record that uniquely identifies
229    /// the record. Used as the state-key suffix. Default: `id`.
230    #[serde(default = "default_parent_key")]
231    pub parent_key: String,
232
233    /// Partial override of `pipeline.source` (deep-merged).
234    #[serde(default)]
235    pub source: Option<PartialConnector>,
236
237    /// Partial override of `pipeline.sink` (deep-merged).
238    #[serde(default)]
239    pub sink: Option<PartialConnector>,
240
241    /// Row-level transforms. Appended after `T_pipeline` and `T_source`
242    /// (unless `inherit_transforms` is `false` here, in which case both
243    /// upstream layers are dropped). `None` or empty list contributes
244    /// nothing.
245    #[serde(default)]
246    pub transforms: Option<Vec<TransformSpec>>,
247
248    /// When `false`, drops upstream `T_pipeline` and `T_source` transforms
249    /// for this row. Default `true`.
250    #[serde(default = "default_true")]
251    pub inherit_transforms: bool,
252
253    /// If `Some`, replaces `pipeline.state` wholesale.
254    #[serde(default)]
255    pub state: Option<StateStoreSpec>,
256
257    /// Matrix-row override semantics:
258    /// - field absent  → `None`     — inherit from `pipeline.dlq`
259    /// - `dlq: null`   → `Some(None)` — disable DLQ for this row
260    /// - `dlq: { ... }` → `Some(Some(spec))` — replace base DLQ wholesale
261    #[serde(default, deserialize_with = "deserialize_dlq_override")]
262    pub dlq: Option<Option<DlqSpec>>,
263
264    /// Per-row delivery override. `None` inherits the top-level `delivery`.
265    #[serde(default)]
266    pub delivery: Option<faucet_core::DeliveryMode>,
267}
268
269/// Execution-time controls.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271#[serde(deny_unknown_fields)]
272pub struct ExecutionSpec {
273    /// Maximum concurrent pipeline invocations (root + per-parent-record
274    /// child invocations all share this budget). Defaults to
275    /// `num_cpus::get().min(4)` at runtime when `None`.
276    #[serde(default)]
277    pub max_concurrent: Option<usize>,
278
279    /// What to do when a pipeline invocation fails.
280    #[serde(default)]
281    pub on_error: OnError,
282
283    /// Adaptive batch-size controller (opt-in). See `faucet_core::AdaptiveBatchConfig`.
284    #[serde(default)]
285    pub adaptive_batch_size: Option<faucet_core::AdaptiveBatchConfig>,
286}
287
288/// Failure-handling policy across the matrix.
289#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
290#[serde(rename_all = "lowercase")]
291pub enum OnError {
292    /// Skip the failed invocation's subtree but keep running siblings (default).
293    #[default]
294    Continue,
295    /// Cancel every pending and in-flight invocation on first failure.
296    Stop,
297}
298
299/// Top-level observability block: Prometheus scrape endpoint and tracing level.
300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
301pub struct ObservabilitySpec {
302    /// Prometheus metrics scrape endpoint configuration.
303    #[serde(default)]
304    pub prometheus: Option<PrometheusSpec>,
305
306    /// Tracing / logging configuration.
307    #[serde(default)]
308    pub tracing: Option<TracingSpec>,
309}
310
311/// Configuration for the Prometheus metrics HTTP endpoint.
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313pub struct PrometheusSpec {
314    /// Socket address to bind the scrape endpoint on (e.g. `"127.0.0.1:9464"`).
315    pub listen: String,
316
317    /// Custom histogram bucket boundaries. Falls back to the Prometheus default
318    /// buckets when `None`.
319    #[serde(default)]
320    pub buckets: Option<Vec<f64>>,
321}
322
323/// Tracing / log-level configuration.
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325pub struct TracingSpec {
326    /// `tracing-subscriber` filter directive (e.g. `"info"`, `"debug"`,
327    /// `"faucet=trace"`). Defaults to the value of `RUST_LOG` when `None`.
328    #[serde(default)]
329    pub level: Option<String>,
330}
331
332/// Mirrors `faucet_core::OnBatchError` but with `JsonSchema` derived and
333/// `Deserialize` accepting the YAML/JSON shape. Converted to the core
334/// type during `executor::build_dlq_config`.
335#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
336#[serde(rename_all = "snake_case")]
337pub enum OnBatchErrorSpec {
338    #[default]
339    Propagate,
340    DlqAll,
341}
342
343/// DLQ configuration block under `pipeline.dlq:`.
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
345#[serde(deny_unknown_fields)]
346pub struct DlqSpec {
347    pub sink: ConnectorSpec,
348    #[serde(default)]
349    pub on_batch_error: OnBatchErrorSpec,
350    #[serde(default)]
351    pub max_failures_per_page: Option<usize>,
352    #[serde(default)]
353    pub max_failures_total: Option<usize>,
354    #[serde(default = "default_true")]
355    pub include_original_payload: bool,
356}
357
358fn default_true() -> bool {
359    true
360}
361
362fn default_version() -> u32 {
363    1
364}
365fn default_parent_key() -> String {
366    "id".to_owned()
367}
368fn empty_object() -> Value {
369    Value::Object(Default::default())
370}
371
372fn deserialize_dlq_override<'de, D>(deserializer: D) -> Result<Option<Option<DlqSpec>>, D::Error>
373where
374    D: serde::Deserializer<'de>,
375{
376    Option::<DlqSpec>::deserialize(deserializer).map(Some)
377}
378
379impl PipelineConfig {
380    /// Load a pipeline config from disk. The file extension determines the
381    /// parser: `.yaml` / `.yml` → YAML, `.json` → JSON. Other extensions are
382    /// rejected.
383    ///
384    /// Composition runs first via [`crate::compose::compose`]: `extends` (base
385    /// inheritance), `profiles` (the named overlay selected by `profile`), and
386    /// `!include` (YAML fragment substitution) are resolved into a single
387    /// merged document before `${...}` interpolation and parsing.
388    ///
389    /// Secret directives (`${vault:…}`, `${aws-sm:…}`, etc.) are **not**
390    /// resolved by this path. If any are present the call returns
391    /// `CliError::SecretsRequireAsyncLoad` — use [`Self::from_path_async`] instead.
392    pub fn from_path(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
393        let path = path.as_ref();
394        let composed = crate::compose::compose(path, profile)?;
395        let interpolated = interpolate(&composed)?;
396        let cfg = Self::from_text(&interpolated, path)?;
397        // Secret directives need the async resolver path; never let them survive
398        // into a connector config as literal `${vault:…}` text.
399        crate::secrets::ensure_no_secret_directives(&cfg)?;
400        Ok(cfg)
401    }
402
403    /// Like [`Self::from_path`] but does not reject secret directives — they are
404    /// left unresolved. Used by `validate --no-secrets`. Composition
405    /// (extends/profiles/`!include`) runs first via [`crate::compose::compose`].
406    pub fn from_path_tolerating_secrets(
407        path: impl AsRef<Path>,
408        profile: Option<&str>,
409    ) -> CliResult<Self> {
410        let path = path.as_ref();
411        let composed = crate::compose::compose(path, profile)?;
412        let interpolated = interpolate(&composed)?;
413        Self::from_text(&interpolated, path)
414    }
415
416    /// Async load path: like [`Self::from_path`] but resolves secret-manager
417    /// directives (`${vault:…}`, `${aws-sm:…}`, …) as a final stage. Composition
418    /// (extends/profiles/`!include`) runs first via [`crate::compose::compose`].
419    pub async fn from_path_async(path: impl AsRef<Path>, profile: Option<&str>) -> CliResult<Self> {
420        let path = path.as_ref();
421        let composed = crate::compose::compose(path, profile)?;
422        let interpolated = interpolate(&composed)?;
423        let mut cfg = Self::from_text(&interpolated, path)?;
424        crate::secrets::resolve_secrets(&mut cfg).await?;
425        Ok(cfg)
426    }
427
428    /// Parse an already-interpolated config string. `path` is only used for
429    /// error messages and to pick the parser by file extension.
430    pub fn from_text(text: &str, path: &Path) -> CliResult<Self> {
431        let ext = path
432            .extension()
433            .and_then(|e| e.to_str())
434            .map(str::to_ascii_lowercase);
435        let cfg: PipelineConfig = match ext.as_deref() {
436            Some("yaml" | "yml") => {
437                serde_yaml::from_str(text).map_err(|e| CliError::ParseConfig {
438                    path: path.to_path_buf(),
439                    message: friendly_parse_error(&e.to_string()),
440                })?
441            }
442            Some("json") => serde_json::from_str(text).map_err(|e| CliError::ParseConfig {
443                path: path.to_path_buf(),
444                message: friendly_parse_error(&e.to_string()),
445            })?,
446            _ => {
447                return Err(CliError::UnknownExtension {
448                    path: path.to_path_buf(),
449                });
450            }
451        };
452        Self::finish(cfg, path)
453    }
454
455    /// Build a config from an already-parsed JSON value (used by `faucet serve`,
456    /// which merges a submitted body onto a `--default-config` base). Runs the
457    /// same version check + structural `${...}` ref resolution as [`Self::from_text`].
458    ///
459    /// **Note:** load-time `${env:VAR}` / `${file:PATH}` / `${secret:VAR}` directives
460    /// are **not** resolved here — the caller must pre-resolve them (e.g. by running
461    /// `interpolate` on the source text) before building the `Value`.
462    pub fn from_value(value: serde_json::Value) -> CliResult<Self> {
463        let synthetic = Path::new("<submitted>");
464        let cfg: PipelineConfig =
465            serde_json::from_value(value).map_err(|e| CliError::ParseConfig {
466                path: synthetic.to_path_buf(),
467                message: friendly_parse_error(&e.to_string()),
468            })?;
469        Self::finish(cfg, synthetic)
470    }
471
472    /// Shared post-parse tail: version gate + structural `${...}` ref resolution.
473    fn finish(mut cfg: PipelineConfig, path: &Path) -> CliResult<Self> {
474        if cfg.version != 1 {
475            return Err(CliError::ParseConfig {
476                path: path.to_path_buf(),
477                message: format!(
478                    "unsupported pipeline version {}, only version 1 is recognised",
479                    cfg.version
480                ),
481            });
482        }
483        crate::interpolate::resolve_config_refs(&mut cfg)?;
484        Ok(cfg)
485    }
486}
487
488/// Translate the typical serde "missing field" message into a hint when the
489/// caller appears to be using the pre-#54 top-level shape.
490fn friendly_parse_error(raw: &str) -> String {
491    let lower = raw.to_ascii_lowercase();
492    if lower.contains("missing field `pipeline`") {
493        return format!(
494            "{raw}\n\nhint: top-level `source:` / `sink:` is no longer supported. Wrap them in a `pipeline:` block — see `faucet init` for the new shape."
495        );
496    }
497    if lower.contains("unknown field `extends`") || lower.contains("unknown field `profiles`") {
498        return format!(
499            "{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."
500        );
501    }
502    raw.to_owned()
503}
504
505/// Convenience: parse a config from text using a synthetic path so the right
506/// parser is selected. Used by tests and the `validate --stdin` flow.
507pub fn parse_with_extension(text: &str, ext: &str) -> CliResult<PipelineConfig> {
508    let synthetic = PathBuf::from(format!("pipeline.{ext}"));
509    PipelineConfig::from_text(text, &synthetic)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use serde_json::json;
516
517    #[test]
518    fn parses_minimal_pipeline_yaml() {
519        let yaml = r#"
520version: 1
521pipeline:
522  source:
523    type: rest
524    config:
525      base_url: https://api.example.com
526  sink:
527    type: jsonl
528    config:
529      path: ./out.jsonl
530"#;
531        let cfg = parse_with_extension(yaml, "yaml").unwrap();
532        assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
533        assert_eq!(cfg.pipeline.sink.as_ref().unwrap().kind, "jsonl");
534        assert!(cfg.matrix.is_empty());
535        assert!(cfg.execution.is_none());
536        assert!(cfg.pipeline.transforms.is_empty());
537        assert!(cfg.pipeline.state.is_none());
538    }
539
540    #[test]
541    fn parses_minimal_json() {
542        let raw = r#"{
543            "version": 1,
544            "pipeline": {
545                "source": {"type": "rest", "config": {}},
546                "sink":   {"type": "jsonl", "config": {"path": "./out.jsonl"}}
547            }
548        }"#;
549        let cfg = parse_with_extension(raw, "json").unwrap();
550        assert_eq!(cfg.pipeline.source.as_ref().unwrap().kind, "rest");
551    }
552
553    #[test]
554    fn parses_matrix_rows_with_partial_overrides() {
555        let yaml = r#"
556version: 1
557pipeline:
558  source: { type: rest, config: { base_url: https://api.example.com } }
559  sink:   { type: jsonl, config: { path: ./out.jsonl } }
560matrix:
561  - id: users
562    source: { config: { path: /v1/users } }
563    sink:   { config: { path: ./users.jsonl } }
564  - id: posts
565    parent: users
566    parent_key: user_id
567    source: { config: { path: "/v1/users/${users.id}/posts" } }
568"#;
569        let cfg = parse_with_extension(yaml, "yaml").unwrap();
570        assert_eq!(cfg.matrix.len(), 2);
571        assert_eq!(cfg.matrix[0].id.as_deref(), Some("users"));
572        assert!(cfg.matrix[0].parent.is_none());
573        let users_src = cfg.matrix[0].source.as_ref().unwrap();
574        assert_eq!(users_src.config.as_ref().unwrap()["path"], "/v1/users");
575
576        assert_eq!(cfg.matrix[1].parent.as_deref(), Some("users"));
577        assert_eq!(cfg.matrix[1].parent_key, "user_id");
578    }
579
580    #[test]
581    fn parent_key_defaults_to_id() {
582        let yaml = r#"
583version: 1
584pipeline:
585  source: { type: rest, config: {} }
586  sink:   { type: jsonl, config: { path: ./o.jsonl } }
587matrix:
588  - { id: users }
589  - { id: posts, parent: users }
590"#;
591        let cfg = parse_with_extension(yaml, "yaml").unwrap();
592        assert_eq!(cfg.matrix[1].parent_key, "id");
593    }
594
595    #[test]
596    fn parses_execution_block() {
597        let yaml = r#"
598version: 1
599pipeline:
600  source: { type: rest, config: {} }
601  sink:   { type: jsonl, config: { path: ./o.jsonl } }
602execution:
603  max_concurrent: 8
604  on_error: stop
605"#;
606        let cfg = parse_with_extension(yaml, "yaml").unwrap();
607        let exec = cfg.execution.unwrap();
608        assert_eq!(exec.max_concurrent, Some(8));
609        assert_eq!(exec.on_error, OnError::Stop);
610    }
611
612    #[test]
613    fn on_error_defaults_to_continue() {
614        let yaml = r#"
615version: 1
616pipeline:
617  source: { type: rest, config: {} }
618  sink:   { type: jsonl, config: { path: ./o.jsonl } }
619execution: { max_concurrent: 2 }
620"#;
621        let cfg = parse_with_extension(yaml, "yaml").unwrap();
622        assert_eq!(cfg.execution.unwrap().on_error, OnError::Continue);
623    }
624
625    #[test]
626    fn rejects_old_top_level_source_sink_with_hint() {
627        // Pre-#54 shape: `source:` and `sink:` at the top level.
628        let yaml = r#"
629version: 1
630source: { type: rest, config: {} }
631sink:   { type: jsonl, config: { path: ./o.jsonl } }
632"#;
633        let err = parse_with_extension(yaml, "yaml").unwrap_err();
634        let msg = err.to_string();
635        assert!(
636            msg.contains("pipeline"),
637            "expected a hint about wrapping in `pipeline:`, got: {msg}"
638        );
639    }
640
641    #[test]
642    fn rejects_unknown_extension() {
643        let text = "version: 1\n";
644        let err = PipelineConfig::from_text(text, Path::new("pipeline.toml")).unwrap_err();
645        assert!(matches!(err, CliError::UnknownExtension { .. }));
646    }
647
648    #[test]
649    fn rejects_future_version() {
650        let yaml = r#"
651version: 99
652pipeline:
653  source: { type: rest, config: {} }
654  sink:   { type: jsonl, config: { path: ./x } }
655"#;
656        let err = parse_with_extension(yaml, "yaml").unwrap_err();
657        match err {
658            CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
659            other => panic!("expected ParseConfig, got {other:?}"),
660        }
661    }
662
663    #[test]
664    fn transforms_and_state_round_trip() {
665        let yaml = r#"
666version: 1
667pipeline:
668  source:
669    type: rest
670    config: {}
671  transforms:
672    - type: snake_case
673    - type: flatten
674      config: { separator: "__" }
675  sink:
676    type: jsonl
677    config: { path: "./out.jsonl" }
678  state:
679    type: file
680    config: { path: "./.faucet-state" }
681"#;
682        let cfg = parse_with_extension(yaml, "yaml").unwrap();
683        assert_eq!(cfg.pipeline.transforms.len(), 2);
684        assert_eq!(cfg.pipeline.transforms[0].kind, "snake_case");
685        assert_eq!(cfg.pipeline.transforms[1].kind, "flatten");
686        assert_eq!(
687            cfg.pipeline.transforms[1].config,
688            json!({"separator": "__"})
689        );
690        let state = cfg.pipeline.state.unwrap();
691        assert_eq!(state.kind, "file");
692    }
693
694    #[test]
695    fn from_path_interpolates_env_var() {
696        unsafe { std::env::set_var("FAUCET_CFG_URL", "https://x.example") };
697        let dir = tempfile::tempdir().unwrap();
698        let path = dir.path().join("pipeline.yaml");
699        std::fs::write(
700            &path,
701            r#"
702version: 1
703pipeline:
704  source:
705    type: rest
706    config:
707      base_url: ${env:FAUCET_CFG_URL}
708  sink:
709    type: jsonl
710    config:
711      path: ./out.jsonl
712"#,
713        )
714        .unwrap();
715        let cfg = PipelineConfig::from_path(&path, None).unwrap();
716        assert_eq!(
717            cfg.pipeline.source.as_ref().unwrap().config["base_url"],
718            "https://x.example"
719        );
720        unsafe { std::env::remove_var("FAUCET_CFG_URL") };
721    }
722
723    #[test]
724    fn observability_block_parses() {
725        let y = r#"
726version: 1
727name: x
728observability:
729  prometheus:
730    listen: "127.0.0.1:9464"
731    buckets: [0.01, 0.1, 1.0]
732  tracing:
733    level: "info"
734pipeline:
735  source:
736    type: rest
737    config:
738      base_url: "https://example.com"
739      path: "/data"
740  sink:
741    type: jsonl
742    config:
743      path: "/tmp/faucet-test.jsonl"
744"#;
745        let cfg: PipelineConfig = serde_yaml::from_str(y).unwrap();
746        let obs = cfg.observability.expect("observability block parsed");
747        let p = obs.prometheus.expect("prometheus parsed");
748        assert_eq!(p.listen, "127.0.0.1:9464");
749        assert_eq!(p.buckets.unwrap().len(), 3);
750        assert_eq!(obs.tracing.unwrap().level.unwrap(), "info");
751    }
752
753    #[test]
754    fn from_path_leaves_id_path_tokens_unresolved_at_load_time() {
755        // `${users.id}` must survive load-time interpolation so the matrix
756        // expander / record-time resolver can handle it later.
757        let dir = tempfile::tempdir().unwrap();
758        let path = dir.path().join("pipeline.yaml");
759        std::fs::write(
760            &path,
761            r#"
762version: 1
763pipeline:
764  source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
765  sink:   { type: jsonl, config: { path: ./o.jsonl } }
766"#,
767        )
768        .unwrap();
769        let cfg = PipelineConfig::from_path(&path, None).unwrap();
770        assert_eq!(
771            cfg.pipeline.source.as_ref().unwrap().config["path"],
772            "/v1/users/${users.id}/posts"
773        );
774    }
775
776    #[cfg(feature = "schedule")]
777    #[test]
778    fn parses_schedule_block() {
779        let yaml = r#"
780version: 1
781schedule:
782  cron: "0 2 * * *"
783  timezone: "America/Los_Angeles"
784  overlap_policy: skip
785  max_consecutive_failures: 5
786pipeline:
787  source: { type: rest, config: {} }
788  sink:   { type: jsonl, config: { path: ./o.jsonl } }
789"#;
790        let cfg = parse_with_extension(yaml, "yaml").unwrap();
791        let s = cfg.schedule.expect("schedule parsed");
792        assert_eq!(s.cron, "0 2 * * *");
793        assert_eq!(s.timezone, "America/Los_Angeles");
794        assert_eq!(s.max_consecutive_failures, Some(5));
795    }
796
797    #[test]
798    fn execution_spec_parses_adaptive_block() {
799        let yaml = r#"
800version: 1
801pipeline:
802  source: { type: rest, config: { base_url: https://api.example.com } }
803  sink:   { type: jsonl, config: { path: ./out.jsonl } }
804execution:
805  adaptive_batch_size:
806    enabled: true
807    min: 200
808    max: 4000
809    target_latency_ms: 800
810"#;
811        let cfg = crate::config::parse_with_extension(yaml, "yaml").unwrap();
812        let ab = cfg.execution.unwrap().adaptive_batch_size.unwrap();
813        assert!(ab.enabled);
814        assert_eq!(ab.min, 200);
815        assert_eq!(ab.target_latency_ms, Some(800));
816        ab.validate().unwrap();
817    }
818
819    #[cfg(feature = "quality")]
820    #[test]
821    fn parses_quality_block() {
822        let yaml = r#"
823version: 1
824pipeline:
825  source: { type: rest, config: { url: "https://x" } }
826  quality:
827    record:
828      - { type: not_null, field: id, on_failure: abort }
829  sink: { type: stdout, config: {} }
830"#;
831        let cfg = parse_with_extension(yaml, "yaml").unwrap();
832        let q = cfg.pipeline.quality.expect("quality parsed");
833        assert_eq!(q.record.len(), 1);
834    }
835
836    #[test]
837    fn parses_dlq_block_with_defaults() {
838        let yaml = r#"
839version: 1
840pipeline:
841  source: { type: rest, config: {} }
842  sink:   { type: jsonl, config: { path: ./o.jsonl } }
843  dlq:
844    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
845"#;
846        let cfg = parse_with_extension(yaml, "yaml").unwrap();
847        let dlq = cfg.pipeline.dlq.expect("dlq parsed");
848        assert_eq!(dlq.sink.kind, "jsonl");
849        assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::Propagate);
850        assert!(dlq.max_failures_per_page.is_none());
851        assert!(dlq.max_failures_total.is_none());
852        assert!(dlq.include_original_payload);
853    }
854
855    #[test]
856    fn parses_dlq_block_with_dlq_all_and_budgets() {
857        let yaml = r#"
858version: 1
859pipeline:
860  source: { type: rest, config: {} }
861  sink:   { type: jsonl, config: { path: ./o.jsonl } }
862  dlq:
863    sink: { type: kafka, config: { brokers: ["b:9092"], topic: dlq } }
864    on_batch_error: dlq_all
865    max_failures_per_page: 100
866    max_failures_total: 10000
867"#;
868        let cfg = parse_with_extension(yaml, "yaml").unwrap();
869        let dlq = cfg.pipeline.dlq.unwrap();
870        assert_eq!(dlq.sink.kind, "kafka");
871        assert_eq!(dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
872        assert_eq!(dlq.max_failures_per_page, Some(100));
873        assert_eq!(dlq.max_failures_total, Some(10000));
874    }
875
876    #[test]
877    fn matrix_row_dlq_null_disables_inherited_dlq() {
878        let yaml = r#"
879version: 1
880pipeline:
881  source: { type: rest, config: {} }
882  sink:   { type: jsonl, config: { path: ./o.jsonl } }
883  dlq:
884    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
885matrix:
886  - id: a
887  - id: b
888    dlq: null
889"#;
890        let cfg = parse_with_extension(yaml, "yaml").unwrap();
891        assert!(cfg.matrix[0].dlq.is_none());
892        assert_eq!(cfg.matrix[1].dlq, Some(None));
893    }
894
895    #[test]
896    fn matrix_row_dlq_object_replaces_inherited_dlq() {
897        let yaml = r#"
898version: 1
899pipeline:
900  source: { type: rest, config: {} }
901  sink:   { type: jsonl, config: { path: ./o.jsonl } }
902  dlq:
903    sink: { type: jsonl, config: { path: ./base.jsonl } }
904matrix:
905  - id: a
906    dlq:
907      sink: { type: jsonl, config: { path: ./a.jsonl } }
908      on_batch_error: dlq_all
909"#;
910        let cfg = parse_with_extension(yaml, "yaml").unwrap();
911        let row_dlq = cfg.matrix[0].dlq.clone().unwrap().unwrap();
912        assert_eq!(row_dlq.on_batch_error, OnBatchErrorSpec::DlqAll);
913        let sink_path = row_dlq.sink.config.get("path").unwrap();
914        assert_eq!(sink_path, "./a.jsonl");
915    }
916
917    #[test]
918    fn parses_named_sources_and_sinks() {
919        let yaml = r#"
920version: 1
921pipeline:
922  sources:
923    users_api:
924      type: rest
925      config: { base_url: https://api.example.com }
926    posts_api:
927      type: rest
928      config: { base_url: https://api.example.com }
929  sinks:
930    warehouse:
931      type: postgres
932      config: { connection_url: "postgres://x" }
933"#;
934        let cfg = parse_with_extension(yaml, "yaml").unwrap();
935        assert!(cfg.pipeline.source.is_none());
936        assert!(cfg.pipeline.sink.is_none());
937        assert_eq!(cfg.pipeline.sources.len(), 2);
938        assert_eq!(cfg.pipeline.sources["users_api"].kind, "rest");
939        assert_eq!(cfg.pipeline.sinks["warehouse"].kind, "postgres");
940    }
941
942    #[test]
943    fn legacy_singular_source_still_parses() {
944        let yaml = r#"
945version: 1
946pipeline:
947  source: { type: rest, config: {} }
948  sink:   { type: jsonl, config: { path: ./o.jsonl } }
949"#;
950        let cfg = parse_with_extension(yaml, "yaml").unwrap();
951        assert!(cfg.pipeline.source.is_some());
952        assert!(cfg.pipeline.sink.is_some());
953        assert!(cfg.pipeline.sources.is_empty());
954        assert!(cfg.pipeline.sinks.is_empty());
955    }
956
957    #[test]
958    fn parses_matrix_row_with_ref_field() {
959        let yaml = r#"
960version: 1
961pipeline:
962  source: { type: rest, config: {} }
963  sink:   { type: jsonl, config: { path: ./o.jsonl } }
964matrix:
965  - id: load_users
966    source:
967      ref: users_api
968      config: { path: /v1/users }
969"#;
970        let cfg = parse_with_extension(yaml, "yaml").unwrap();
971        let src = cfg.matrix[0].source.as_ref().unwrap();
972        assert_eq!(src.r#ref.as_deref(), Some("users_api"));
973        assert_eq!(src.kind, None);
974        assert_eq!(src.config.as_ref().unwrap()["path"], "/v1/users");
975    }
976
977    #[test]
978    fn parses_top_level_vars_block() {
979        let yaml = r#"
980version: 1
981vars:
982  api_base: https://api.example.com
983  api_token_env: API_TOKEN
984pipeline:
985  source: { type: rest, config: {} }
986  sink:   { type: jsonl, config: { path: ./o.jsonl } }
987"#;
988        let cfg = parse_with_extension(yaml, "yaml").unwrap();
989        let vars = cfg.vars.as_ref().unwrap();
990        assert_eq!(vars["api_base"], "https://api.example.com");
991        assert_eq!(vars["api_token_env"], "API_TOKEN");
992    }
993
994    #[test]
995    fn vars_block_is_optional() {
996        let yaml = r#"
997version: 1
998pipeline:
999  source: { type: rest, config: {} }
1000  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1001"#;
1002        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1003        assert!(cfg.vars.is_none());
1004    }
1005
1006    #[test]
1007    fn from_path_resolves_vars_at_load() {
1008        let dir = tempfile::tempdir().unwrap();
1009        let path = dir.path().join("pipeline.yaml");
1010        std::fs::write(
1011            &path,
1012            r#"
1013version: 1
1014vars:
1015  base: https://api.example.com
1016pipeline:
1017  source: { type: rest, config: { url: "${vars.base}/v1" } }
1018  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1019"#,
1020        )
1021        .unwrap();
1022        let cfg = PipelineConfig::from_path(&path, None).unwrap();
1023        assert_eq!(
1024            cfg.pipeline.source.as_ref().unwrap().config["url"],
1025            "https://api.example.com/v1"
1026        );
1027    }
1028
1029    #[test]
1030    fn sync_from_path_errors_on_secret_directive() {
1031        let dir = tempfile::tempdir().unwrap();
1032        let path = dir.path().join("p.yaml");
1033        std::fs::write(
1034            &path,
1035            r#"
1036version: 1
1037pipeline:
1038  source: { type: rest, config: { url: "${vault:secret/x}" } }
1039  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1040"#,
1041        )
1042        .unwrap();
1043        match PipelineConfig::from_path(&path, None).unwrap_err() {
1044            CliError::SecretsRequireAsyncLoad => {}
1045            other => panic!("expected SecretsRequireAsyncLoad, got {other:?}"),
1046        }
1047    }
1048
1049    #[test]
1050    fn from_value_accepts_v1_and_resolves_refs() {
1051        let v = serde_json::json!({
1052            "version": 1,
1053            "vars": { "out": "resolved.jsonl" },
1054            "pipeline": {
1055                "source": { "type": "csv",  "config": { "path": "x.csv" } },
1056                "sink":   { "type": "jsonl", "config": { "path": "${vars.out}" } }
1057            }
1058        });
1059        let cfg = PipelineConfig::from_value(v).unwrap();
1060        assert_eq!(cfg.version, 1);
1061        // structural ${vars.*} refs are resolved by from_value (via finish → resolve_config_refs)
1062        assert_eq!(cfg.pipeline.sink.unwrap().config["path"], "resolved.jsonl");
1063    }
1064
1065    #[test]
1066    fn from_value_rejects_non_v1() {
1067        // structurally valid config; only the version is wrong
1068        let v = serde_json::json!({ "version": 99, "pipeline": {} });
1069        let err = PipelineConfig::from_value(v).unwrap_err();
1070        match err {
1071            CliError::ParseConfig { message, .. } => assert!(message.contains("version 99")),
1072            other => panic!("expected ParseConfig, got {other:?}"),
1073        }
1074    }
1075
1076    #[tokio::test]
1077    async fn async_from_path_loads_without_secrets() {
1078        let dir = tempfile::tempdir().unwrap();
1079        let path = dir.path().join("p.yaml");
1080        std::fs::write(
1081            &path,
1082            r#"
1083version: 1
1084pipeline:
1085  source: { type: rest, config: { base_url: https://x } }
1086  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1087"#,
1088        )
1089        .unwrap();
1090        let cfg = PipelineConfig::from_path_async(&path, None).await.unwrap();
1091        assert_eq!(cfg.version, 1);
1092    }
1093
1094    #[cfg(feature = "lineage")]
1095    #[test]
1096    fn parses_lineage_block() {
1097        let yaml = r#"
1098version: 1
1099lineage:
1100  namespace: prod
1101  transport: { type: file, config: { path: /tmp/ol.jsonl } }
1102pipeline:
1103  source: { type: rest, config: {} }
1104  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1105"#;
1106        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1107        let l = cfg.lineage.expect("lineage parsed");
1108        assert_eq!(l.namespace, "prod");
1109    }
1110
1111    #[test]
1112    fn from_path_resolves_extends_and_profile() {
1113        let dir = tempfile::tempdir().unwrap();
1114        std::fs::write(
1115            dir.path().join("base.yaml"),
1116            "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",
1117        )
1118        .unwrap();
1119        let app = dir.path().join("app.yaml");
1120        std::fs::write(&app, "extends: ./base.yaml\n").unwrap();
1121
1122        // No profile → base sink path.
1123        let cfg = PipelineConfig::from_path(&app, None).unwrap();
1124        assert_eq!(
1125            cfg.pipeline.sink.as_ref().unwrap().config["path"],
1126            "base.jsonl"
1127        );
1128
1129        // --profile prod → overridden sink path.
1130        let cfg = PipelineConfig::from_path(&app, Some("prod")).unwrap();
1131        assert_eq!(
1132            cfg.pipeline.sink.as_ref().unwrap().config["path"],
1133            "prod.jsonl"
1134        );
1135    }
1136
1137    #[test]
1138    fn from_value_rejects_extends_with_composition_hint() {
1139        // A submitted body (serve path) must not silently accept `extends`.
1140        let v = serde_json::json!({
1141            "version": 1,
1142            "extends": "base.yaml",
1143            "pipeline": { "source": { "type": "csv", "config": {} }, "sink": { "type": "jsonl", "config": {} } }
1144        });
1145        let err = PipelineConfig::from_value(v).unwrap_err();
1146        let msg = err.to_string();
1147        assert!(
1148            msg.contains("composition"),
1149            "expected composition hint, got: {msg}"
1150        );
1151    }
1152
1153    #[test]
1154    fn delivery_defaults_to_at_least_once_and_parses_exactly_once() {
1155        // Default when omitted: top-level delivery should be AtLeastOnce.
1156        let yaml = r#"
1157version: 1
1158pipeline:
1159  source: { type: rest, config: {} }
1160  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1161"#;
1162        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1163        assert_eq!(cfg.delivery, faucet_core::DeliveryMode::AtLeastOnce);
1164
1165        // Explicit exactly_once at top level.
1166        let yaml2 = r#"
1167version: 1
1168delivery: exactly_once
1169pipeline:
1170  source: { type: rest, config: {} }
1171  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1172"#;
1173        let cfg2 = parse_with_extension(yaml2, "yaml").unwrap();
1174        assert_eq!(cfg2.delivery, faucet_core::DeliveryMode::ExactlyOnce);
1175
1176        // Matrix row: absent delivery inherits (None), explicit overrides.
1177        let yaml3 = r#"
1178version: 1
1179delivery: at_least_once
1180pipeline:
1181  source: { type: rest, config: {} }
1182  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1183matrix:
1184  - id: a
1185  - id: b
1186    delivery: exactly_once
1187"#;
1188        let cfg3 = parse_with_extension(yaml3, "yaml").unwrap();
1189        assert_eq!(cfg3.matrix[0].delivery, None);
1190        assert_eq!(
1191            cfg3.matrix[1].delivery,
1192            Some(faucet_core::DeliveryMode::ExactlyOnce)
1193        );
1194    }
1195}