Skip to main content

faucet_cli/
expand.rs

1//! Expand a parsed [`PipelineConfig`] into a flat list of [`ExpandedNode`]s
2//! ready for the executor to run.
3//!
4//! Responsibilities:
5//! - Assign synthetic ids to anonymous rows (`row-0`, `row-1`, …).
6//! - Reject reserved row ids (`env`, `file`, `secret`, `matrix`, `pipeline`).
7//! - Reject duplicate ids.
8//! - Validate that every `parent:` references a known row id and that the
9//!   parent chain has no cycles.
10//! - Deep-merge each row's partial overrides into `pipeline.*`.
11//! - Find every `${id.path}` token surviving from load-time interpolation and
12//!   record where each one came from. Tokens that reference unknown ids
13//!   produce a `CliError::UnknownInterpolationId` here, not at runtime.
14
15use crate::config::{
16    ConnectorSpec, MatrixRow, PartialConnector, PipelineConfig, PipelineSpec, StateStoreSpec,
17    TransformSpec,
18};
19use crate::error::{CliError, CliResult};
20use crate::interpolate::{Directive, iter_directives};
21use crate::merge::merge_value;
22use serde_json::Value;
23use std::collections::{BTreeSet, HashMap, HashSet};
24
25/// Row ids that callers can never use because they collide with
26/// load-time interpolation prefixes or future runtime scopes.
27pub const RESERVED_IDS: &[&str] = &["env", "file", "secret", "matrix", "pipeline", "now"];
28
29/// One fully-merged matrix row, ready for the executor.
30#[derive(Debug, Clone)]
31pub struct ExpandedNode {
32    pub id: String,
33    pub row_index: usize,
34    pub role: NodeRole,
35    pub source: ConnectorSpec,
36    pub sink: ConnectorSpec,
37    pub transforms: Vec<TransformSpec>,
38    pub state: Option<StateStoreSpec>,
39    /// Resolved DLQ spec for this row, or `None` if no DLQ applies.
40    pub dlq: Option<crate::config::DlqSpec>,
41    /// Pipeline-level quality spec, shared by every node. `quality:` has no
42    /// matrix-row override in v1, so this is `cfg.pipeline.quality` verbatim.
43    #[cfg(feature = "quality")]
44    pub quality: Option<faucet_core::QualitySpec>,
45    /// Compiled schema-drift policy spec (pipeline-level; same for every node).
46    pub schema: Option<faucet_core::SchemaDriftSpec>,
47    /// Delivery guarantee for this row. Resolved from the row's override or
48    /// falls back to the top-level `cfg.delivery`.
49    pub delivery: faucet_core::DeliveryMode,
50    /// Every `${id.path}` placeholder that survived load-time interpolation.
51    /// Populated by `collect_deferred`; the executor uses this to know
52    /// which parent record to feed which row.
53    pub deferred_refs: Vec<DeferredRef>,
54}
55
56#[derive(Debug, Clone)]
57pub enum NodeRole {
58    /// Root node — runs once per pipeline invocation.
59    Root,
60    /// Child node — runs once per record produced by the parent row.
61    Child {
62        parent_id: String,
63        parent_key: String,
64    },
65}
66
67#[derive(Debug, Clone)]
68pub struct DeferredRef {
69    pub referenced_id: String,
70    pub dotted_path: String,
71    pub token: String,
72}
73
74/// In-memory lookup of source / sink templates, built once per `expand()` call.
75/// Combines named entries from `pipeline.sources` / `pipeline.sinks` with the
76/// legacy singular `pipeline.source` / `pipeline.sink` (registered as `default`).
77struct Registry<'a> {
78    sources: HashMap<&'a str, &'a ConnectorSpec>,
79    sinks: HashMap<&'a str, &'a ConnectorSpec>,
80}
81
82impl<'a> Registry<'a> {
83    fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
84        let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
85        if let Some(default) = spec.source.as_ref() {
86            sources.insert("default", default);
87        }
88        for (name, s) in spec.sources.iter() {
89            if sources.contains_key(name.as_str()) {
90                return Err(CliError::DuplicateTemplate {
91                    kind: "source",
92                    name: name.clone(),
93                });
94            }
95            sources.insert(name.as_str(), s);
96        }
97
98        let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
99        if let Some(default) = spec.sink.as_ref() {
100            if default.transforms.is_some() {
101                return Err(CliError::TransformsOnSink {
102                    name: "default".to_string(),
103                });
104            }
105            if !default.inherit_transforms {
106                return Err(CliError::InheritTransformsOnSink {
107                    name: "default".to_string(),
108                });
109            }
110            sinks.insert("default", default);
111        }
112        for (name, s) in spec.sinks.iter() {
113            if sinks.contains_key(name.as_str()) {
114                return Err(CliError::DuplicateTemplate {
115                    kind: "sink",
116                    name: name.clone(),
117                });
118            }
119            if s.transforms.is_some() {
120                return Err(CliError::TransformsOnSink { name: name.clone() });
121            }
122            if !s.inherit_transforms {
123                return Err(CliError::InheritTransformsOnSink { name: name.clone() });
124            }
125            sinks.insert(name.as_str(), s);
126        }
127        Ok(Self { sources, sinks })
128    }
129
130    fn known(&self, kind: &'static str) -> Vec<String> {
131        debug_assert!(
132            matches!(kind, "source" | "sink"),
133            "Registry::known called with kind = {:?}",
134            kind
135        );
136        let map = if kind == "source" {
137            &self.sources
138        } else {
139            &self.sinks
140        };
141        let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
142        out.sort();
143        out
144    }
145
146    fn resolve(
147        &self,
148        kind: &'static str,
149        row_id: &str,
150        overlay: Option<&PartialConnector>,
151    ) -> CliResult<ConnectorSpec> {
152        debug_assert!(
153            matches!(kind, "source" | "sink"),
154            "Registry::resolve called with kind = {:?}",
155            kind
156        );
157        let map = if kind == "source" {
158            &self.sources
159        } else {
160            &self.sinks
161        };
162        let ref_name = overlay
163            .and_then(|p| p.r#ref.as_deref())
164            .unwrap_or("default");
165        let base = map.get(ref_name).ok_or_else(|| {
166            if ref_name == "default" {
167                CliError::MissingTemplate {
168                    kind,
169                    row_id: row_id.to_owned(),
170                }
171            } else {
172                CliError::UnknownTemplate {
173                    kind,
174                    name: ref_name.to_owned(),
175                    row_id: row_id.to_owned(),
176                    known: self.known(kind),
177                }
178            }
179        })?;
180        let mut out = (*base).clone();
181        if let Some(p) = overlay {
182            if let Some(k) = &p.kind {
183                out.kind = k.clone();
184            }
185            if let Some(c) = &p.config {
186                merge_value(&mut out.config, c.clone());
187            }
188        }
189        Ok(out)
190    }
191}
192
193/// Expand `cfg` into a topologically valid list of nodes. Roots come first,
194/// then children in BFS order.
195pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
196    // Fail-fast at config load: validate the execution-level adaptive
197    // batch-size controller here (the shared `validate`/`run`/`preview`/
198    // `doctor`/`schedule` gate) so `faucet validate` rejects a bad block
199    // rather than only surfacing it mid-run in the executor.
200    if let Some(ab) = cfg
201        .execution
202        .as_ref()
203        .and_then(|e| e.adaptive_batch_size.as_ref())
204    {
205        // `validate()` returns `FaucetError::Config` whose message already names
206        // the offending field; propagate it directly (CliError: From<FaucetError>).
207        ab.validate()?;
208    }
209
210    // Implicit single-row case: empty matrix → run pipeline once with no merge.
211    let synthetic_row;
212    let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
213        synthetic_row = [MatrixRow {
214            id: None,
215            parent: None,
216            parent_key: "id".into(),
217            source: None,
218            sink: None,
219            transforms: None,
220            inherit_transforms: true,
221            state: None,
222            dlq: None,
223            delivery: None,
224        }];
225        &synthetic_row
226    } else {
227        &cfg.matrix
228    };
229
230    // 1) Assign / validate ids.
231    let mut ids: Vec<String> = Vec::with_capacity(rows.len());
232    let mut seen: HashSet<String> = HashSet::new();
233    for (i, row) in rows.iter().enumerate() {
234        let id = match &row.id {
235            Some(s) => s.clone(),
236            None => format!("row-{i}"),
237        };
238        if RESERVED_IDS.contains(&id.as_str()) {
239            return Err(CliError::ReservedRowId { id });
240        }
241        if !seen.insert(id.clone()) {
242            return Err(CliError::DuplicateRowId { id });
243        }
244        ids.push(id);
245    }
246    let id_set: HashSet<&str> = ids.iter().map(String::as_str).collect();
247
248    // 2) Validate parents + detect cycles.
249    let mut parents: HashMap<&str, &str> = HashMap::new();
250    for (i, row) in rows.iter().enumerate() {
251        let id = ids[i].as_str();
252        if let Some(parent) = row.parent.as_deref() {
253            if !id_set.contains(parent) {
254                return Err(CliError::UnknownParent {
255                    id: id.to_owned(),
256                    parent: parent.to_owned(),
257                });
258            }
259            if parent == id {
260                return Err(CliError::ParentCycle {
261                    ids: vec![id.to_owned()],
262                });
263            }
264            parents.insert(id, parent);
265        }
266    }
267    detect_cycle(&parents)?;
268
269    // 3) Validate `${id.path}` references — each `id` must be a known row.
270    // We scan the *raw* (pre-merge) row configs because interpolation lives in
271    // strings that survive merging unchanged.
272    for (i, row) in rows.iter().enumerate() {
273        let id = ids[i].as_str();
274        if let Some(p) = &row.source
275            && let Some(c) = &p.config
276        {
277            check_refs(c, &id_set, id)?;
278        }
279        if let Some(p) = &row.sink
280            && let Some(c) = &p.config
281        {
282            check_refs(c, &id_set, id)?;
283        }
284    }
285    if let Some(s) = &cfg.pipeline.source {
286        check_refs(&s.config, &id_set, "pipeline.source")?;
287    }
288    if let Some(s) = &cfg.pipeline.sink {
289        check_refs(&s.config, &id_set, "pipeline.sink")?;
290    }
291    for (name, s) in &cfg.pipeline.sources {
292        check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
293    }
294    for (name, s) in &cfg.pipeline.sinks {
295        check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
296    }
297
298    // 4) Build template registry — validates duplicate default conflicts.
299    let registry = Registry::build(&cfg.pipeline)?;
300
301    // 5) Build expanded nodes. Order: roots first (in declaration order),
302    // then BFS over children — guarantees a parent appears before its children.
303    let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
304    let mut roots: Vec<usize> = Vec::new();
305    for (i, row) in rows.iter().enumerate() {
306        match row.parent.as_deref() {
307            None => roots.push(i),
308            Some(p) => by_parent.entry(p).or_default().push(i),
309        }
310    }
311
312    let mut order: Vec<usize> = Vec::with_capacity(rows.len());
313    let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
314    while let Some(idx) = queue.pop_front() {
315        order.push(idx);
316        if let Some(children) = by_parent.get(ids[idx].as_str()) {
317            queue.extend(children.iter().copied());
318        }
319    }
320    debug_assert_eq!(order.len(), rows.len());
321
322    let mut out = Vec::with_capacity(rows.len());
323    for &i in &order {
324        let row = &rows[i];
325        let row_id = ids[i].as_str();
326        let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
327        let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
328        let role = match &row.parent {
329            None => NodeRole::Root,
330            Some(p) => NodeRole::Child {
331                parent_id: p.clone(),
332                parent_key: row.parent_key.clone(),
333            },
334        };
335        let mut deferred = Vec::new();
336        collect_deferred(&merged_source.config, &mut deferred);
337        collect_deferred(&merged_sink.config, &mut deferred);
338
339        // Resolve transforms, state, and DLQ (row overrides win over base).
340        // Three-layer additive resolution:
341        //   T_pipeline ++ T_source ++ T_row
342        // gated on each layer's `inherit_transforms` flag.
343        let src_inherit = merged_source.inherit_transforms;
344        let row_inherit = row.inherit_transforms;
345        let mut transforms: Vec<TransformSpec> = Vec::new();
346        if src_inherit && row_inherit {
347            transforms.extend(cfg.pipeline.transforms.iter().cloned());
348        }
349        if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
350            transforms.extend(src_ts.iter().cloned());
351        }
352        if let Some(row_ts) = row.transforms.as_ref() {
353            transforms.extend(row_ts.iter().cloned());
354        }
355        let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
356        // Row override wins; fall back to the top-level delivery mode.
357        let delivery = row.delivery.unwrap_or(cfg.delivery);
358        // Three-state match: Some(None) = disable, Some(Some(spec)) = replace,
359        // None = inherit. The naive `.flatten().or_else()` would conflate
360        // disable and absent, silently inheriting on explicit null.
361        let dlq = match row.dlq.clone() {
362            Some(None) => None,
363            Some(Some(spec)) => Some(spec),
364            None => cfg.pipeline.dlq.clone(),
365        };
366
367        if let Some(ref d) = dlq {
368            if matches!(d.max_failures_per_page, Some(0)) {
369                return Err(CliError::InvalidDlqBudget {
370                    field: "max_failures_per_page",
371                });
372            }
373            if matches!(d.max_failures_total, Some(0)) {
374                return Err(CliError::InvalidDlqBudget {
375                    field: "max_failures_total",
376                });
377            }
378            if !crate::registry::sink_exists(&d.sink.kind) {
379                return Err(CliError::UnknownDlqSinkKind {
380                    kind: d.sink.kind.clone(),
381                    context: format!("row `{row_id}`"),
382                });
383            }
384        }
385
386        // Runtime interpolation (`${row.path}` parent refs and `${now.*}`) is
387        // resolved only in source/sink configs. A token in a transform / state
388        // / dlq config would otherwise reach the connector as a literal
389        // `${...}` string with no error (#146 M2) — reject it at expand time.
390        for (ti, t) in transforms.iter().enumerate() {
391            reject_runtime_tokens(
392                &t.config,
393                &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
394            )?;
395        }
396        if let Some(ref st) = state {
397            reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
398        }
399        if let Some(ref d) = dlq {
400            reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
401        }
402
403        // `quality:` is pipeline-level only in v1 (no matrix-row override), so
404        // every node carries the same spec. Compile it once per node to (a)
405        // surface invalid paths/regexes/bounds at expand time, and (b) fail
406        // fast when a quarantine check has no DLQ to route to — the core guards
407        // this at run start too, but catching it here makes `faucet validate`
408        // a friendly, fast failure.
409        #[cfg(feature = "quality")]
410        let quality = cfg.pipeline.quality.clone();
411        #[cfg(feature = "quality")]
412        if let Some(ref spec) = quality {
413            let compiled = faucet_core::CompiledQuality::compile(spec)
414                .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
415            if compiled.requires_dlq() && dlq.is_none() {
416                return Err(CliError::Config(format!(
417                    "row `{row_id}`: a quality check uses `on_failure: quarantine` \
418                     but no DLQ is configured — add a `dlq:` block (or change the \
419                     check's `on_failure` to `abort`)"
420                )));
421            }
422        }
423
424        // Exactly-once delivery gate: enforce source, sink, state, and DLQ
425        // compatibility at config-load time so `faucet validate` catches
426        // unsupported combinations before any run starts.
427        if delivery == faucet_core::DeliveryMode::ExactlyOnce {
428            if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
429                return Err(CliError::Config(format!(
430                    "row '{}': delivery: exactly_once is not supported by source '{}' \
431                     (deterministic-replay sources only: {})",
432                    ids[i],
433                    merged_source.kind,
434                    crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", ")
435                )));
436            }
437            if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
438                return Err(CliError::Config(format!(
439                    "row '{}': delivery: exactly_once is not supported by sink '{}' \
440                     (idempotent sinks only: {})",
441                    ids[i],
442                    merged_sink.kind,
443                    crate::registry::IDEMPOTENT_SINK_KINDS.join(", ")
444                )));
445            }
446            // Require a *durable* state store. The exactly-once mechanism
447            // persists the monotonic page sequence alongside the bookmark
448            // (`wrap_state(bookmark, seq)`) and resumes from it across
449            // restarts; the in-process `memory` store loses that watermark on
450            // exit, so a restart would re-run already-committed pages — exactly
451            // the duplication exactly-once exists to prevent (F24). Mirror the
452            // `faucet replicate` gate, which already rejects `memory`.
453            match state.as_ref() {
454                None => {
455                    return Err(CliError::Config(format!(
456                        "row '{}': delivery: exactly_once requires a state store",
457                        ids[i]
458                    )));
459                }
460                Some(s) if s.kind == "memory" => {
461                    return Err(CliError::Config(format!(
462                        "row '{}': delivery: exactly_once requires a durable state store, \
463                         not `memory` — the cross-restart watermark/sequence guarantee \
464                         depends on it (use `file`, `redis`, or `postgres`)",
465                        ids[i]
466                    )));
467                }
468                Some(_) => {}
469            }
470            if dlq.is_some() {
471                return Err(CliError::Config(format!(
472                    "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
473                    ids[i]
474                )));
475            }
476        }
477
478        // Resilience poison-pill cross-check: `poison.action: dlq` routes
479        // persistently-failing rows to the DLQ, so a DLQ must be configured.
480        // Caught here so `faucet validate` reports it before any run starts.
481        if let Some(spec) = &cfg.resilience
482            && matches!(
483                spec.poison.as_ref().map(|p| p.action),
484                Some(crate::config::PoisonActionSpec::Dlq)
485            )
486            && dlq.is_none()
487        {
488            return Err(CliError::Config(format!(
489                "row '{row_id}': resilience.poison.action=dlq requires a dlq: block"
490            )));
491        }
492
493        // write_mode × sink validation (load-time): reject an unsupported mode
494        // for the sink kind, and upsert/delete without a key, before any run.
495        // Runs for every row; append rows pass trivially.
496        let requested_mode = merged_sink
497            .config
498            .get("write_mode")
499            .and_then(|v| v.as_str())
500            .unwrap_or("append");
501        let mode = match requested_mode {
502            "append" => faucet_core::WriteMode::Append,
503            "upsert" => faucet_core::WriteMode::Upsert,
504            "delete" => faucet_core::WriteMode::Delete,
505            other => {
506                return Err(CliError::Config(format!(
507                    "row '{}': unknown write_mode '{}' (expected append, upsert, or delete)",
508                    ids[i], other
509                )));
510            }
511        };
512        if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
513            return Err(CliError::Config(format!(
514                "row '{}': write_mode '{}' is not supported by sink '{}' \
515                 (upsert/delete sinks: {})",
516                ids[i],
517                requested_mode,
518                merged_sink.kind,
519                crate::registry::UPSERT_SINK_KINDS.join(", ")
520            )));
521        }
522        if matches!(
523            mode,
524            faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
525        ) {
526            let key_present = merged_sink
527                .config
528                .get("key")
529                .and_then(|v| v.as_array())
530                .map(|a| !a.is_empty())
531                .unwrap_or(false);
532            if !key_present {
533                return Err(CliError::Config(format!(
534                    "row '{}': write_mode '{}' requires a non-empty `key`",
535                    ids[i], requested_mode
536                )));
537            }
538        }
539
540        // Schema-drift policy gates (load-time):
541        //  - `evolve` requires an evolution-capable sink.
542        //  - `quarantine` (drift or incompatible) requires a DLQ, and is
543        //    incompatible with exactly-once (which forbids a DLQ).
544        if let Some(ref sd) = cfg.pipeline.schema {
545            let policy = faucet_core::SchemaDriftPolicy::compile(sd);
546            if policy.on_drift == faucet_core::OnDrift::Evolve
547                && !crate::registry::sink_supports_schema_evolution(&merged_sink.kind)
548            {
549                return Err(CliError::Config(format!(
550                    "row '{}': schema.on_drift: evolve is not supported by sink '{}' \
551                     (evolvable sinks: postgres, mysql, mssql, sqlite, bigquery, elasticsearch)",
552                    ids[i], merged_sink.kind
553                )));
554            }
555            if policy.requires_dlq() && dlq.is_none() {
556                return Err(CliError::Config(format!(
557                    "row '{}': schema.on_drift/on_incompatible 'quarantine' requires a `dlq:` block",
558                    ids[i]
559                )));
560            }
561            if policy.requires_dlq() && delivery == faucet_core::DeliveryMode::ExactlyOnce {
562                return Err(CliError::Config(format!(
563                    "row '{}': schema quarantine is incompatible with delivery: exactly_once \
564                     (exactly_once forbids a DLQ)",
565                    ids[i]
566                )));
567            }
568        }
569
570        out.push(ExpandedNode {
571            id: ids[i].clone(),
572            row_index: i,
573            role,
574            source: merged_source,
575            sink: merged_sink,
576            transforms,
577            state,
578            dlq,
579            delivery,
580            #[cfg(feature = "quality")]
581            quality,
582            schema: cfg.pipeline.schema.clone(),
583            deferred_refs: deferred,
584        });
585    }
586    Ok(out)
587}
588
589fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
590    // Each node has at most one parent ⇒ cycle detection is "walk parents
591    // until we hit `None` or revisit a node we've already seen this walk".
592    for &start in parents.keys() {
593        let mut visited: BTreeSet<&str> = BTreeSet::new();
594        let mut cur = start;
595        while let Some(&p) = parents.get(cur) {
596            if !visited.insert(cur) {
597                let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
598                return Err(CliError::ParentCycle { ids: chain });
599            }
600            cur = p;
601            if cur == start {
602                let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
603                chain.push(start.to_string());
604                return Err(CliError::ParentCycle { ids: chain });
605            }
606        }
607    }
608    Ok(())
609}
610
611/// Verify that every `${X.path}` token in `value` has `X` listed in `id_set`.
612/// Load-time prefixes (`env`, `file`, `secret`) were already handled and are
613/// ignored here.
614fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
615    walk_strings(value, &mut |s| {
616        for (token, dir) in iter_directives(s) {
617            // Load-time / template directives (`${env:..}`, `${vars.X}`, …) are
618            // resolved before expansion; only deferred `${id.path}` references
619            // are validated here, against the known row ids.
620            // `now` is a reserved built-in deferred id resolved at run time.
621            if let Directive::Deferred { id, .. } = dir
622                && id != "now"
623                && !id_set.contains(id)
624            {
625                return Err(CliError::UnknownInterpolationId {
626                    id: id.to_owned(),
627                    token: format!("{token} (in {owner})"),
628                });
629            }
630        }
631        Ok(())
632    })
633}
634
635/// Reject any runtime interpolation token (`${id.path}` parent-record refs and
636/// `${now.*}`) found in `value`. These resolve **only** in source/sink configs;
637/// elsewhere — transform / state / dlq bodies — they would silently reach the
638/// connector as a literal `${...}` string (#146 M2). Load-time directives
639/// (`${env:}`, `${vars.X}`, `${sources.X}`, …) are already resolved before
640/// expansion, so any deferred token still present here is genuinely
641/// unsupported in this location.
642fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
643    walk_strings(value, &mut |s| {
644        for (token, dir) in iter_directives(s) {
645            if let Directive::Deferred { .. } = dir {
646                return Err(CliError::Config(format!(
647                    "interpolation token `{token}` in {location} is not supported: \
648                     `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
649                     resolve only in source/sink configs"
650                )));
651            }
652        }
653        Ok(())
654    })
655}
656
657fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
658    let _ = walk_strings(value, &mut |s| {
659        for (token, dir) in iter_directives(s) {
660            if let Directive::Deferred { id, path } = dir {
661                // `now` is a reserved built-in resolved at run time, not a
662                // parent-record dependency — skip it so the executor doesn't
663                // treat it as a deferred parent-record reference.
664                if id == "now" {
665                    continue;
666                }
667                out.push(DeferredRef {
668                    referenced_id: id.to_owned(),
669                    dotted_path: path.to_owned(),
670                    token: token.to_owned(),
671                });
672            }
673        }
674        Ok(())
675    });
676}
677
678fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
679where
680    F: FnMut(&str) -> CliResult<()>,
681{
682    match value {
683        Value::String(s) => f(s),
684        Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
685        Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
686        _ => Ok(()),
687    }
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use crate::config::{OnBatchErrorSpec, parse_with_extension};
694
695    fn cfg(yaml: &str) -> PipelineConfig {
696        parse_with_extension(yaml, "yaml").unwrap()
697    }
698
699    #[test]
700    fn implicit_single_row_when_matrix_absent() {
701        let c = cfg(r#"
702version: 1
703pipeline:
704  source: { type: rest, config: { base_url: https://x } }
705  sink:   { type: jsonl, config: { path: ./o } }
706"#);
707        let nodes = expand(&c).unwrap();
708        assert_eq!(nodes.len(), 1);
709        assert_eq!(nodes[0].id, "row-0");
710        assert!(matches!(nodes[0].role, NodeRole::Root));
711        assert_eq!(nodes[0].source.kind, "rest");
712        assert_eq!(nodes[0].sink.kind, "jsonl");
713    }
714
715    #[test]
716    fn rejects_runtime_token_in_dlq_config() {
717        // M2 (#146): `${now.*}` / `${parent.path}` resolve only in source/sink
718        // configs. In a dlq config they would silently pass through as a literal
719        // `${...}` string — expand must reject them with a clear error.
720        let c = cfg(r#"
721version: 1
722pipeline:
723  source: { type: rest, config: { base_url: https://x } }
724  sink:   { type: jsonl, config: { path: ./o } }
725  dlq:
726    sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
727"#);
728        let err = expand(&c).unwrap_err();
729        assert!(
730            matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
731            "got: {err:?}"
732        );
733    }
734
735    #[test]
736    fn rejects_runtime_token_in_state_config() {
737        let c = cfg(r#"
738version: 1
739pipeline:
740  source: { type: rest, config: { base_url: https://x } }
741  sink:   { type: jsonl, config: { path: ./o } }
742  state:
743    type: file
744    config: { path: "state-${now.date}" }
745"#);
746        let err = expand(&c).unwrap_err();
747        assert!(
748            matches!(&err, CliError::Config(m) if m.contains("state")),
749            "got: {err:?}"
750        );
751    }
752
753    #[test]
754    fn rejects_runtime_token_in_transform_config() {
755        let c = cfg(r#"
756version: 1
757pipeline:
758  source: { type: rest, config: { base_url: https://x } }
759  sink:   { type: jsonl, config: { path: ./o } }
760  transforms:
761    - type: set
762      config: { field: ts, value: "${now.datetime}" }
763"#);
764        let err = expand(&c).unwrap_err();
765        assert!(
766            matches!(&err, CliError::Config(m) if m.contains("transform")),
767            "got: {err:?}"
768        );
769    }
770
771    #[test]
772    fn allows_runtime_token_in_source_and_sink_configs() {
773        // The same tokens remain valid in source/sink configs (regression guard
774        // that M2's rejection didn't over-reach).
775        let c = cfg(r#"
776version: 1
777pipeline:
778  source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
779  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
780"#);
781        let nodes = expand(&c).unwrap();
782        assert_eq!(nodes.len(), 1);
783    }
784
785    #[test]
786    fn merges_row_overrides_into_pipeline_source() {
787        let c = cfg(r#"
788version: 1
789pipeline:
790  source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
791  sink:   { type: jsonl, config: { path: ./o } }
792matrix:
793  - id: users
794    source: { config: { path: /v1/users, headers: { b: 2 } } }
795"#);
796        let nodes = expand(&c).unwrap();
797        assert_eq!(nodes[0].id, "users");
798        assert_eq!(nodes[0].source.config["base_url"], "https://x");
799        assert_eq!(nodes[0].source.config["path"], "/v1/users");
800        assert_eq!(nodes[0].source.config["headers"]["a"], 1);
801        assert_eq!(nodes[0].source.config["headers"]["b"], 2);
802    }
803
804    #[test]
805    fn errors_on_unknown_parent() {
806        let c = cfg(r#"
807version: 1
808pipeline:
809  source: { type: rest, config: {} }
810  sink:   { type: jsonl, config: { path: ./o } }
811matrix:
812  - id: child
813    parent: nobody
814"#);
815        assert!(matches!(
816            expand(&c).unwrap_err(),
817            CliError::UnknownParent { .. }
818        ));
819    }
820
821    #[test]
822    fn errors_on_duplicate_ids() {
823        let c = cfg(r#"
824version: 1
825pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
826matrix:
827  - { id: x }
828  - { id: x }
829"#);
830        assert!(matches!(
831            expand(&c).unwrap_err(),
832            CliError::DuplicateRowId { .. }
833        ));
834    }
835
836    #[test]
837    fn errors_on_reserved_id() {
838        let c = cfg(r#"
839version: 1
840pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
841matrix:
842  - { id: env }
843"#);
844        assert!(matches!(
845            expand(&c).unwrap_err(),
846            CliError::ReservedRowId { .. }
847        ));
848    }
849
850    #[test]
851    fn errors_on_self_parent_cycle() {
852        let c = cfg(r#"
853version: 1
854pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
855matrix:
856  - { id: a, parent: a }
857"#);
858        assert!(matches!(
859            expand(&c).unwrap_err(),
860            CliError::ParentCycle { .. }
861        ));
862    }
863
864    #[test]
865    fn errors_on_two_node_cycle() {
866        let c = cfg(r#"
867version: 1
868pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
869matrix:
870  - { id: a, parent: b }
871  - { id: b, parent: a }
872"#);
873        assert!(matches!(
874            expand(&c).unwrap_err(),
875            CliError::ParentCycle { .. }
876        ));
877    }
878
879    #[test]
880    fn errors_on_unknown_interpolation_id() {
881        let c = cfg(r#"
882version: 1
883pipeline:
884  source: { type: rest, config: { url: "https://x/${nobody.id}" } }
885  sink:   { type: jsonl, config: { path: ./o } }
886"#);
887        assert!(matches!(
888            expand(&c).unwrap_err(),
889            CliError::UnknownInterpolationId { .. }
890        ));
891    }
892
893    #[test]
894    fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
895        // Regression for #78/#39: `${env.foo}` has no colon, so it is a
896        // deferred reference to id `env`, not a load-time `env:` directive.
897        // The validator must reject it (as the runtime would), rather than
898        // silently skipping it and letting `run` fail later.
899        let c = cfg(r#"
900version: 1
901pipeline:
902  source: { type: rest, config: { url: "https://x/${env.foo}" } }
903  sink:   { type: jsonl, config: { path: ./o } }
904"#);
905        match expand(&c).unwrap_err() {
906            CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
907            other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
908        }
909    }
910
911    #[test]
912    fn accepts_id_path_when_referenced_row_exists() {
913        let c = cfg(r#"
914version: 1
915pipeline:
916  source: { type: rest, config: {} }
917  sink:   { type: jsonl, config: { path: ./o } }
918matrix:
919  - id: users
920  - id: posts
921    parent: users
922    source: { config: { path: "/v1/users/${users.id}/posts" } }
923"#);
924        let nodes = expand(&c).unwrap();
925        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
926        assert_eq!(posts.deferred_refs.len(), 1);
927        assert_eq!(posts.deferred_refs[0].referenced_id, "users");
928        assert_eq!(posts.deferred_refs[0].dotted_path, "id");
929    }
930
931    #[test]
932    fn nested_referenced_path_resolves() {
933        let c = cfg(r#"
934version: 1
935pipeline:
936  source: { type: rest, config: {} }
937  sink:   { type: jsonl, config: { path: ./o } }
938matrix:
939  - id: users
940  - id: addrs
941    parent: users
942    source: { config: { path: "/users/${users.addr.city}/addr" } }
943"#);
944        let nodes = expand(&c).unwrap();
945        let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
946        assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
947    }
948
949    #[test]
950    fn roots_come_before_children_in_order() {
951        let c = cfg(r#"
952version: 1
953pipeline:
954  source: { type: rest, config: {} }
955  sink:   { type: jsonl, config: { path: ./o } }
956matrix:
957  - id: posts
958    parent: users
959  - id: users
960"#);
961        let nodes = expand(&c).unwrap();
962        let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
963        let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
964        assert!(users_idx < posts_idx, "users must precede posts");
965    }
966
967    #[test]
968    fn child_node_has_parent_role() {
969        let c = cfg(r#"
970version: 1
971pipeline:
972  source: { type: rest, config: {} }
973  sink:   { type: jsonl, config: { path: ./o } }
974matrix:
975  - id: users
976  - id: posts
977    parent: users
978    parent_key: user_id
979"#);
980        let nodes = expand(&c).unwrap();
981        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
982        match &posts.role {
983            NodeRole::Child {
984                parent_id,
985                parent_key,
986            } => {
987                assert_eq!(parent_id, "users");
988                assert_eq!(parent_key, "user_id");
989            }
990            other => panic!("expected Child, got {other:?}"),
991        }
992    }
993
994    #[test]
995    fn expand_rejects_zero_per_page_budget() {
996        let yaml = r#"
997version: 1
998pipeline:
999  source: { type: rest, config: {} }
1000  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1001  dlq:
1002    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1003    max_failures_per_page: 0
1004"#;
1005        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1006        let err = expand(&cfg).unwrap_err();
1007        assert!(matches!(
1008            err,
1009            CliError::InvalidDlqBudget {
1010                field: "max_failures_per_page"
1011            }
1012        ));
1013    }
1014
1015    #[test]
1016    fn expand_rejects_zero_total_budget() {
1017        let yaml = r#"
1018version: 1
1019pipeline:
1020  source: { type: rest, config: {} }
1021  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1022  dlq:
1023    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1024    max_failures_total: 0
1025"#;
1026        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1027        let err = expand(&cfg).unwrap_err();
1028        assert!(matches!(
1029            err,
1030            CliError::InvalidDlqBudget {
1031                field: "max_failures_total"
1032            }
1033        ));
1034    }
1035
1036    #[test]
1037    fn expand_rejects_unknown_dlq_sink_kind() {
1038        let yaml = r#"
1039version: 1
1040pipeline:
1041  source: { type: rest, config: {} }
1042  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1043  dlq:
1044    sink: { type: not_a_sink, config: {} }
1045"#;
1046        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1047        let err = expand(&cfg).unwrap_err();
1048        assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
1049    }
1050
1051    #[cfg(feature = "quality")]
1052    #[test]
1053    fn expand_rejects_quarantine_without_dlq() {
1054        // A quality check with `on_failure: quarantine` needs a DLQ to route to.
1055        // `expand` must reject the config so `faucet validate` fails fast.
1056        let yaml = r#"
1057version: 1
1058pipeline:
1059  source: { type: rest, config: {} }
1060  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1061  quality:
1062    record:
1063      - { type: not_null, field: id, on_failure: quarantine }
1064"#;
1065        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1066        let err = expand(&cfg).unwrap_err();
1067        match err {
1068            CliError::Config(msg) => {
1069                assert!(msg.contains("quarantine"), "{msg}");
1070                assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
1071            }
1072            other => panic!("expected Config error, got {other:?}"),
1073        }
1074    }
1075
1076    #[cfg(feature = "quality")]
1077    #[test]
1078    fn expand_accepts_quarantine_with_dlq() {
1079        let yaml = r#"
1080version: 1
1081pipeline:
1082  source: { type: rest, config: {} }
1083  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1084  dlq:
1085    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1086  quality:
1087    record:
1088      - { type: not_null, field: id, on_failure: quarantine }
1089"#;
1090        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1091        let nodes = expand(&cfg).unwrap();
1092        assert_eq!(nodes.len(), 1);
1093        let q = nodes[0]
1094            .quality
1095            .as_ref()
1096            .expect("quality threaded onto node");
1097        assert_eq!(q.record.len(), 1);
1098    }
1099
1100    #[cfg(feature = "quality")]
1101    #[test]
1102    fn expand_accepts_abort_quality_without_dlq() {
1103        // `on_failure: abort` does not route to a DLQ, so no DLQ is required.
1104        let yaml = r#"
1105version: 1
1106pipeline:
1107  source: { type: rest, config: {} }
1108  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1109  quality:
1110    record:
1111      - { type: not_null, field: id, on_failure: abort }
1112"#;
1113        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1114        let nodes = expand(&cfg).unwrap();
1115        assert!(nodes[0].quality.is_some());
1116    }
1117
1118    #[test]
1119    fn legacy_singular_source_resolves_as_default_template() {
1120        let c = cfg(r#"
1121version: 1
1122pipeline:
1123  source: { type: rest, config: { base_url: https://x } }
1124  sink:   { type: jsonl, config: { path: ./o } }
1125"#);
1126        let nodes = expand(&c).unwrap();
1127        assert_eq!(nodes[0].source.kind, "rest");
1128        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1129    }
1130
1131    #[test]
1132    fn row_with_ref_picks_named_template() {
1133        let c = cfg(r#"
1134version: 1
1135pipeline:
1136  sources:
1137    users_api: { type: rest, config: { base_url: https://x } }
1138  sinks:
1139    archive:   { type: jsonl, config: { path: ./out } }
1140matrix:
1141  - id: load_users
1142    source:
1143      ref: users_api
1144      config: { path: /v1/users }
1145    sink:
1146      ref: archive
1147      config: { path: ./users.jsonl }
1148"#);
1149        let nodes = expand(&c).unwrap();
1150        assert_eq!(nodes[0].source.kind, "rest");
1151        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1152        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1153        assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1154    }
1155
1156    #[test]
1157    fn row_without_ref_falls_back_to_default_template() {
1158        let c = cfg(r#"
1159version: 1
1160pipeline:
1161  source: { type: rest, config: { base_url: https://x } }
1162  sink:   { type: jsonl, config: { path: ./o } }
1163matrix:
1164  - id: users
1165    source: { config: { path: /v1/users } }
1166"#);
1167        let nodes = expand(&c).unwrap();
1168        assert_eq!(nodes[0].source.kind, "rest");
1169        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1170    }
1171
1172    #[test]
1173    fn unknown_template_ref_errors_with_known_list() {
1174        let c = cfg(r#"
1175version: 1
1176pipeline:
1177  sources:
1178    a: { type: rest, config: {} }
1179    b: { type: rest, config: {} }
1180  sinks:
1181    s: { type: jsonl, config: { path: ./o } }
1182matrix:
1183  - id: x
1184    source: { ref: c }
1185    sink: { ref: s }
1186"#);
1187        let err = expand(&c).unwrap_err();
1188        match err {
1189            CliError::UnknownTemplate {
1190                kind,
1191                name,
1192                row_id,
1193                known,
1194            } => {
1195                assert_eq!(kind, "source");
1196                assert_eq!(name, "c");
1197                assert_eq!(row_id, "x");
1198                assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1199            }
1200            other => panic!("expected UnknownTemplate, got {other:?}"),
1201        }
1202    }
1203
1204    #[test]
1205    fn missing_default_template_errors() {
1206        // No singular `source:` and no `sources.default` — a row without a ref
1207        // has nowhere to go.
1208        let c = cfg(r#"
1209version: 1
1210pipeline:
1211  sources:
1212    users_api: { type: rest, config: {} }
1213  sink: { type: jsonl, config: { path: ./o } }
1214matrix:
1215  - id: x
1216    source: { config: { path: /v1 } }
1217"#);
1218        let err = expand(&c).unwrap_err();
1219        match err {
1220            CliError::MissingTemplate { kind, row_id } => {
1221                assert_eq!(kind, "source");
1222                assert_eq!(row_id, "x");
1223            }
1224            other => panic!("expected MissingTemplate, got {other:?}"),
1225        }
1226    }
1227
1228    #[test]
1229    fn duplicate_default_template_errors() {
1230        // Defining both legacy `source:` and `sources.default:` is a conflict.
1231        let c = cfg(r#"
1232version: 1
1233pipeline:
1234  source: { type: rest, config: {} }
1235  sources:
1236    default: { type: rest, config: {} }
1237  sink: { type: jsonl, config: { path: ./o } }
1238"#);
1239        let err = expand(&c).unwrap_err();
1240        match err {
1241            CliError::DuplicateTemplate { kind, name } => {
1242                assert_eq!(kind, "source");
1243                assert_eq!(name, "default");
1244            }
1245            other => panic!("expected DuplicateTemplate, got {other:?}"),
1246        }
1247    }
1248
1249    #[test]
1250    fn row_can_override_template_kind() {
1251        let c = cfg(r#"
1252version: 1
1253pipeline:
1254  sources:
1255    api: { type: rest, config: { base_url: https://x } }
1256  sinks:
1257    out: { type: jsonl, config: { path: ./o } }
1258matrix:
1259  - id: x
1260    source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1261    sink: { ref: out }
1262"#);
1263        let nodes = expand(&c).unwrap();
1264        assert_eq!(nodes[0].source.kind, "graphql");
1265        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1266        assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1267    }
1268
1269    #[test]
1270    fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1271        let yaml = r#"
1272version: 1
1273pipeline:
1274  source: { type: rest, config: {} }
1275  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1276  dlq:
1277    sink: { type: jsonl, config: { path: ./base.jsonl } }
1278matrix:
1279  - id: a
1280  - id: b
1281    dlq: null
1282  - id: c
1283    dlq:
1284      sink: { type: jsonl, config: { path: ./c.jsonl } }
1285      on_batch_error: dlq_all
1286"#;
1287        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1288        let nodes = expand(&cfg).unwrap();
1289        assert_eq!(nodes.len(), 3);
1290        // Row a inherits.
1291        assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1292        assert_eq!(
1293            nodes[0]
1294                .dlq
1295                .as_ref()
1296                .unwrap()
1297                .sink
1298                .config
1299                .get("path")
1300                .unwrap(),
1301            "./base.jsonl"
1302        );
1303        // Row b is disabled.
1304        assert!(nodes[1].dlq.is_none());
1305        // Row c is replaced.
1306        assert_eq!(
1307            nodes[2].dlq.as_ref().unwrap().on_batch_error,
1308            OnBatchErrorSpec::DlqAll
1309        );
1310        assert_eq!(
1311            nodes[2]
1312                .dlq
1313                .as_ref()
1314                .unwrap()
1315                .sink
1316                .config
1317                .get("path")
1318                .unwrap(),
1319            "./c.jsonl"
1320        );
1321    }
1322
1323    #[test]
1324    fn multiple_rows_pick_different_templates() {
1325        let c = cfg(r#"
1326version: 1
1327pipeline:
1328  sources:
1329    users_api:  { type: rest, config: { base_url: https://users.example } }
1330    orders_api: { type: rest, config: { base_url: https://orders.example } }
1331  sinks:
1332    archive: { type: jsonl, config: { path: ./out } }
1333matrix:
1334  - id: load_users
1335    source: { ref: users_api, config: { path: /v1/users } }
1336    sink:   { ref: archive,   config: { path: ./users.jsonl } }
1337  - id: load_orders
1338    source: { ref: orders_api, config: { path: /v1/orders } }
1339    sink:   { ref: archive,    config: { path: ./orders.jsonl } }
1340"#);
1341        let nodes = expand(&c).unwrap();
1342        assert_eq!(nodes.len(), 2);
1343        let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1344        let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1345        assert_eq!(users.source.config["base_url"], "https://users.example");
1346        assert_eq!(users.source.config["path"], "/v1/users");
1347        assert_eq!(orders.source.config["base_url"], "https://orders.example");
1348        assert_eq!(orders.source.config["path"], "/v1/orders");
1349        // Both rows share the same sink template but pick different output paths.
1350        assert_eq!(users.sink.config["path"], "./users.jsonl");
1351        assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1352    }
1353
1354    #[test]
1355    fn sink_template_with_transforms_errors_at_expand() {
1356        let yaml = r#"
1357version: 1
1358pipeline:
1359  source:
1360    type: rest
1361    config: {}
1362  sinks:
1363    bad:
1364      type: jsonl
1365      config: { destination: /tmp/x.jsonl }
1366      transforms:
1367        - { type: flatten, config: { separator: "_" } }
1368matrix:
1369  - id: row
1370    sink: { ref: bad }
1371"#;
1372        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1373            .unwrap();
1374        let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1375        match err {
1376            crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1377            other => panic!("expected TransformsOnSink, got {other:?}"),
1378        }
1379    }
1380
1381    #[test]
1382    fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1383        let yaml = r#"
1384version: 1
1385pipeline:
1386  source:
1387    type: rest
1388    config: {}
1389  sinks:
1390    bad:
1391      type: jsonl
1392      config: { destination: /tmp/x.jsonl }
1393      inherit_transforms: false
1394matrix:
1395  - id: row
1396    sink: { ref: bad }
1397"#;
1398        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1399            .unwrap();
1400        let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1401        match err {
1402            crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1403            other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1404        }
1405    }
1406
1407    fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1408        transforms.iter().map(|t| t.kind.clone()).collect()
1409    }
1410
1411    #[test]
1412    fn three_layer_concat_default_inherit() {
1413        let yaml = r#"
1414version: 1
1415pipeline:
1416  transforms:
1417    - { type: flatten, config: { separator: "_" } }
1418  sources:
1419    s:
1420      type: rest
1421      config: {}
1422      transforms:
1423        - { type: keys_case, config: { mode: snake } }
1424  sink:
1425    type: jsonl
1426    config: { destination: /tmp/x.jsonl }
1427matrix:
1428  - id: row
1429    source: { ref: s }
1430    transforms:
1431      - { type: select, config: { fields: [id] } }
1432"#;
1433        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1434            .unwrap();
1435        let nodes = crate::expand::expand(&cfg).unwrap();
1436        assert_eq!(nodes.len(), 1);
1437        assert_eq!(
1438            kinds(&nodes[0].transforms),
1439            vec!["flatten", "keys_case", "select"]
1440        );
1441    }
1442
1443    #[test]
1444    fn source_inherit_false_drops_pipeline_layer() {
1445        let yaml = r#"
1446version: 1
1447pipeline:
1448  transforms:
1449    - { type: flatten, config: { separator: "_" } }
1450  sources:
1451    s:
1452      type: rest
1453      config: {}
1454      inherit_transforms: false
1455      transforms:
1456        - { type: keys_case, config: { mode: snake } }
1457  sink:
1458    type: jsonl
1459    config: { destination: /tmp/x.jsonl }
1460matrix:
1461  - id: row
1462    source: { ref: s }
1463    transforms:
1464      - { type: select, config: { fields: [id] } }
1465"#;
1466        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1467            .unwrap();
1468        let nodes = crate::expand::expand(&cfg).unwrap();
1469        assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1470    }
1471
1472    #[test]
1473    fn row_inherit_false_drops_pipeline_and_source_layers() {
1474        let yaml = r#"
1475version: 1
1476pipeline:
1477  transforms:
1478    - { type: flatten, config: { separator: "_" } }
1479  sources:
1480    s:
1481      type: rest
1482      config: {}
1483      transforms:
1484        - { type: keys_case, config: { mode: snake } }
1485  sink:
1486    type: jsonl
1487    config: { destination: /tmp/x.jsonl }
1488matrix:
1489  - id: row
1490    source: { ref: s }
1491    inherit_transforms: false
1492    transforms:
1493      - { type: select, config: { fields: [id] } }
1494"#;
1495        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1496            .unwrap();
1497        let nodes = crate::expand::expand(&cfg).unwrap();
1498        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1499    }
1500
1501    #[test]
1502    fn both_inherit_false_yields_row_only() {
1503        let yaml = r#"
1504version: 1
1505pipeline:
1506  transforms:
1507    - { type: flatten, config: { separator: "_" } }
1508  sources:
1509    s:
1510      type: rest
1511      config: {}
1512      inherit_transforms: false
1513      transforms:
1514        - { type: keys_case, config: { mode: snake } }
1515  sink:
1516    type: jsonl
1517    config: { destination: /tmp/x.jsonl }
1518matrix:
1519  - id: row
1520    source: { ref: s }
1521    inherit_transforms: false
1522    transforms:
1523      - { type: select, config: { fields: [id] } }
1524"#;
1525        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1526            .unwrap();
1527        let nodes = crate::expand::expand(&cfg).unwrap();
1528        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1529    }
1530
1531    #[test]
1532    fn all_layers_omitted_yields_empty_transforms() {
1533        let yaml = r#"
1534version: 1
1535pipeline:
1536  source:
1537    type: rest
1538    config: {}
1539  sink:
1540    type: jsonl
1541    config: { destination: /tmp/x.jsonl }
1542matrix:
1543  - id: row
1544"#;
1545        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1546            .unwrap();
1547        let nodes = crate::expand::expand(&cfg).unwrap();
1548        assert!(nodes[0].transforms.is_empty());
1549    }
1550
1551    #[test]
1552    fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
1553        // A root pipeline referencing ${now.date} must pass expand validation.
1554        let yaml = r#"
1555version: 1
1556pipeline:
1557  source: { type: rest, config: {} }
1558  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1559"#;
1560        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1561        // expand must NOT raise UnknownInterpolationId for `now`.
1562        assert!(expand(&cfg).is_ok());
1563    }
1564
1565    #[test]
1566    fn now_is_a_reserved_row_id() {
1567        let yaml = r#"
1568version: 1
1569pipeline:
1570  source: { type: rest, config: {} }
1571  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1572matrix:
1573  - id: now
1574"#;
1575        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1576        match expand(&cfg).unwrap_err() {
1577            CliError::ReservedRowId { id } => assert_eq!(id, "now"),
1578            other => panic!("expected ReservedRowId, got {other:?}"),
1579        }
1580    }
1581
1582    #[test]
1583    fn expand_rejects_invalid_adaptive_batch_size_at_load() {
1584        // Fail-fast: an invalid execution.adaptive_batch_size block must be
1585        // rejected by `expand` (the gate `faucet validate` uses), not only at
1586        // run time in the executor.
1587        let yaml = r#"
1588version: 1
1589pipeline:
1590  source: { type: rest, config: {} }
1591  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1592execution:
1593  adaptive_batch_size:
1594    enabled: true
1595    min: 5000
1596    max: 100
1597"#;
1598        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1599        let err = expand(&cfg).unwrap_err();
1600        assert!(
1601            err.to_string().contains("adaptive_batch_size.min"),
1602            "expected adaptive validation error, got: {err}"
1603        );
1604    }
1605
1606    #[test]
1607    fn expand_accepts_valid_adaptive_batch_size() {
1608        let yaml = r#"
1609version: 1
1610pipeline:
1611  source: { type: rest, config: {} }
1612  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1613execution:
1614  adaptive_batch_size:
1615    enabled: true
1616    min: 100
1617    max: 5000
1618    target_latency_ms: 500
1619"#;
1620        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1621        assert!(expand(&cfg).is_ok());
1622    }
1623
1624    // --- exactly-once delivery gate tests ---
1625
1626    #[test]
1627    fn exactly_once_rejects_non_cdc_source() {
1628        // rest→stdout with exactly_once must fail: rest is not replay-capable.
1629        let yaml = r#"
1630version: 1
1631delivery: exactly_once
1632pipeline:
1633  source: { type: rest, config: { base_url: https://x } }
1634  sink:   { type: stdout, config: {} }
1635  state:
1636    type: memory
1637    config: {}
1638"#;
1639        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1640        let err = expand(&cfg).unwrap_err();
1641        match &err {
1642            CliError::Config(msg) => {
1643                assert!(
1644                    msg.contains("rest"),
1645                    "expected source kind in error, got: {msg}"
1646                );
1647                assert!(
1648                    msg.contains("exactly_once") || msg.contains("not supported"),
1649                    "got: {msg}"
1650                );
1651            }
1652            other => panic!("expected Config error, got {other:?}"),
1653        }
1654    }
1655
1656    #[test]
1657    fn exactly_once_rejects_non_idempotent_sink() {
1658        // postgres-cdc→stdout: source is OK but stdout is not idempotent.
1659        let yaml = r#"
1660version: 1
1661delivery: exactly_once
1662pipeline:
1663  source: { type: postgres-cdc, config: {} }
1664  sink:   { type: stdout, config: {} }
1665  state:
1666    type: memory
1667    config: {}
1668"#;
1669        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1670        let err = expand(&cfg).unwrap_err();
1671        match &err {
1672            CliError::Config(msg) => {
1673                assert!(
1674                    msg.contains("stdout"),
1675                    "expected sink kind in error, got: {msg}"
1676                );
1677                assert!(
1678                    msg.contains("exactly_once") || msg.contains("not supported"),
1679                    "got: {msg}"
1680                );
1681            }
1682            other => panic!("expected Config error, got {other:?}"),
1683        }
1684    }
1685
1686    #[test]
1687    fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
1688        // postgres-cdc → sqlite + a *durable* state store → must expand
1689        // successfully. (Must not be `memory`: exactly-once needs cross-restart
1690        // durability — see `exactly_once_rejects_memory_state`.)
1691        let yaml = r#"
1692version: 1
1693delivery: exactly_once
1694pipeline:
1695  source: { type: postgres-cdc, config: {} }
1696  sink:   { type: sqlite, config: {} }
1697  state:
1698    type: file
1699    config: { path: "/tmp/faucet-eo-state.json" }
1700"#;
1701        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1702        let nodes = expand(&cfg).unwrap();
1703        assert_eq!(nodes.len(), 1);
1704        assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
1705    }
1706
1707    #[test]
1708    fn exactly_once_rejects_memory_state() {
1709        // A non-durable `memory` store defeats the cross-restart watermark
1710        // guarantee, so it must be rejected at config-load (F24).
1711        let yaml = r#"
1712version: 1
1713delivery: exactly_once
1714pipeline:
1715  source: { type: postgres-cdc, config: {} }
1716  sink:   { type: sqlite, config: {} }
1717  state:
1718    type: memory
1719    config: {}
1720"#;
1721        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1722        let err = expand(&cfg).unwrap_err();
1723        match &err {
1724            CliError::Config(msg) => assert!(
1725                msg.contains("durable") && msg.contains("memory"),
1726                "expected durable/memory mention, got: {msg}"
1727            ),
1728            other => panic!("expected Config error, got {other:?}"),
1729        }
1730    }
1731
1732    #[test]
1733    fn exactly_once_rejects_missing_state_store() {
1734        // Valid CDC pair but no state block → must fail with "requires a state store".
1735        let yaml = r#"
1736version: 1
1737delivery: exactly_once
1738pipeline:
1739  source: { type: postgres-cdc, config: {} }
1740  sink:   { type: sqlite, config: {} }
1741"#;
1742        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1743        let err = expand(&cfg).unwrap_err();
1744        match &err {
1745            CliError::Config(msg) => {
1746                assert!(
1747                    msg.contains("state store") || msg.contains("state"),
1748                    "expected state-store mention in error, got: {msg}"
1749                );
1750            }
1751            other => panic!("expected Config error, got {other:?}"),
1752        }
1753    }
1754
1755    #[test]
1756    fn rejects_upsert_on_unsupported_sink() {
1757        let c = cfg(r#"
1758version: 1
1759name: t
1760pipeline:
1761  source: { type: rest, config: { url: "http://x" } }
1762  sink:   { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
1763"#);
1764        let err = expand(&c).unwrap_err();
1765        let msg = format!("{err}");
1766        assert!(
1767            msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
1768            "{msg}"
1769        );
1770    }
1771
1772    #[test]
1773    fn rejects_upsert_without_key() {
1774        let c = cfg(r#"
1775version: 1
1776name: t
1777pipeline:
1778  source: { type: rest, config: { url: "http://x" } }
1779  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
1780"#);
1781        let err = expand(&c).unwrap_err();
1782        let msg = format!("{err}");
1783        assert!(msg.contains("key"), "{msg}");
1784    }
1785
1786    #[test]
1787    fn accepts_upsert_on_postgres_with_key() {
1788        let c = cfg(r#"
1789version: 1
1790name: t
1791pipeline:
1792  source: { type: rest, config: { url: "http://x" } }
1793  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1794"#);
1795        assert!(expand(&c).is_ok());
1796    }
1797
1798    #[test]
1799    fn bigquery_upsert_passes_write_mode_gate() {
1800        let c = cfg(r#"
1801version: 1
1802name: t
1803pipeline:
1804  source: { type: rest, config: { url: "http://x" } }
1805  sink:   { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
1806"#);
1807        assert!(expand(&c).is_ok());
1808    }
1809
1810    #[test]
1811    fn accepts_append_by_default_on_any_sink() {
1812        let c = cfg(r#"
1813version: 1
1814name: t
1815pipeline:
1816  source: { type: rest, config: { url: "http://x" } }
1817  sink:   { type: jsonl, config: { path: "out.jsonl" } }
1818"#);
1819        assert!(expand(&c).is_ok());
1820    }
1821
1822    #[test]
1823    fn rejects_delete_without_key() {
1824        let c = cfg(r#"
1825version: 1
1826name: t
1827pipeline:
1828  source: { type: rest, config: { url: "http://x" } }
1829  sink:   { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
1830"#);
1831        let err = expand(&c).unwrap_err();
1832        let msg = format!("{err}");
1833        assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
1834    }
1835
1836    #[test]
1837    fn rejects_unknown_write_mode() {
1838        let c = cfg(r#"
1839version: 1
1840name: t
1841pipeline:
1842  source: { type: rest, config: { url: "http://x" } }
1843  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
1844"#);
1845        let err = expand(&c).unwrap_err();
1846        let msg = format!("{err}");
1847        assert!(
1848            msg.contains("unknown write_mode") && msg.contains("replace"),
1849            "{msg}"
1850        );
1851    }
1852
1853    #[test]
1854    fn rejects_poison_dlq_action_without_dlq() {
1855        let c = cfg(r#"
1856version: 1
1857pipeline:
1858  source: { type: rest, config: { base_url: https://x } }
1859  sink:   { type: jsonl, config: { path: ./o } }
1860resilience:
1861  poison: { max_row_attempts: 3, action: dlq }
1862"#);
1863        let err = expand(&c).unwrap_err();
1864        assert!(
1865            matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
1866            "got: {err:?}"
1867        );
1868    }
1869
1870    #[test]
1871    fn accepts_poison_dlq_action_with_dlq() {
1872        let c = cfg(r#"
1873version: 1
1874pipeline:
1875  source: { type: rest, config: { base_url: https://x } }
1876  sink:   { type: jsonl, config: { path: ./o } }
1877  dlq:
1878    sink: { type: jsonl, config: { path: ./dead.jsonl } }
1879resilience:
1880  poison: { max_row_attempts: 3, action: dlq }
1881"#);
1882        let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
1883        assert_eq!(nodes.len(), 1);
1884    }
1885
1886    #[test]
1887    fn accepts_poison_drop_action_without_dlq() {
1888        // action=drop discards rows in place, so no DLQ is required.
1889        let c = cfg(r#"
1890version: 1
1891pipeline:
1892  source: { type: rest, config: { base_url: https://x } }
1893  sink:   { type: jsonl, config: { path: ./o } }
1894resilience:
1895  poison: { max_row_attempts: 3, action: drop }
1896"#);
1897        let nodes = expand(&c).expect("poison.action=drop needs no dlq");
1898        assert_eq!(nodes.len(), 1);
1899    }
1900
1901    // --- schema-drift composition gate tests ---
1902
1903    #[test]
1904    fn evolve_on_non_evolvable_sink_rejected() {
1905        // jsonl is not evolution-capable; on_drift: evolve must fail.
1906        let c = cfg(r#"
1907version: 1
1908pipeline:
1909  source: { type: rest, config: { base_url: https://x } }
1910  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1911  schema:
1912    on_drift: evolve
1913"#);
1914        let err = expand(&c).unwrap_err();
1915        match &err {
1916            CliError::Config(msg) => {
1917                assert!(
1918                    msg.contains("evolve"),
1919                    "expected evolve mention, got: {msg}"
1920                );
1921                assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
1922            }
1923            other => panic!("expected Config error, got {other:?}"),
1924        }
1925    }
1926
1927    #[test]
1928    fn quarantine_drift_without_dlq_rejected() {
1929        // on_drift: quarantine requires a dlq: block.
1930        let c = cfg(r#"
1931version: 1
1932pipeline:
1933  source: { type: rest, config: { base_url: https://x } }
1934  sink:   { type: postgres, config: {} }
1935  schema:
1936    on_drift: quarantine
1937"#);
1938        let err = expand(&c).unwrap_err();
1939        match &err {
1940            CliError::Config(msg) => {
1941                assert!(
1942                    msg.contains("quarantine"),
1943                    "expected quarantine mention, got: {msg}"
1944                );
1945                assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
1946            }
1947            other => panic!("expected Config error, got {other:?}"),
1948        }
1949    }
1950
1951    #[test]
1952    fn evolve_on_postgres_passes() {
1953        // postgres is evolution-capable; on_drift: evolve must expand.
1954        let c = cfg(r#"
1955version: 1
1956pipeline:
1957  source: { type: rest, config: { base_url: https://x } }
1958  sink:   { type: postgres, config: {} }
1959  schema:
1960    on_drift: evolve
1961"#);
1962        assert!(expand(&c).is_ok());
1963    }
1964}