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