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] = &[
28    "env", "file", "secret", "matrix", "pipeline", "now", "backfill",
29];
30
31/// One fully-merged matrix row, ready for the executor.
32#[derive(Debug, Clone)]
33pub struct ExpandedNode {
34    pub id: String,
35    pub row_index: usize,
36    pub role: NodeRole,
37    pub source: ConnectorSpec,
38    pub sink: ConnectorSpec,
39    pub transforms: Vec<TransformSpec>,
40    pub state: Option<StateStoreSpec>,
41    /// Resolved DLQ spec for this row, or `None` if no DLQ applies.
42    pub dlq: Option<crate::config::DlqSpec>,
43    /// Pipeline-level quality spec, shared by every node. `quality:` has no
44    /// matrix-row override in v1, so this is `cfg.pipeline.quality` verbatim.
45    #[cfg(feature = "quality")]
46    pub quality: Option<faucet_core::QualitySpec>,
47    /// Pipeline-level data contract, shared by every node (`contract:` has no
48    /// matrix-row override in v1) — `cfg.pipeline.contract` verbatim.
49    #[cfg(feature = "contract")]
50    pub contract: Option<faucet_core::ContractSpec>,
51    /// Pipeline-level PII masking policy, shared by every node (`masking:` has
52    /// no matrix-row override in v1) — `cfg.pipeline.masking` verbatim. The
53    /// executor compiles it *scoped to this node's sink* ([`sink_ref`] +
54    /// [`sink`].`kind`) so `applies_to` destination-scoping works (#206).
55    ///
56    /// [`sink_ref`]: ExpandedNode::sink_ref
57    /// [`sink`]: ExpandedNode::sink
58    #[cfg(feature = "masking")]
59    pub masking: Option<faucet_core::MaskingSpec>,
60    /// The sink template name this node resolved (`sink.ref`, or `"default"`
61    /// for the legacy singular `pipeline.sink`). Used to scope masking
62    /// `applies_to` rules per destination.
63    pub sink_ref: String,
64    /// Compiled schema-drift policy spec (pipeline-level; same for every node).
65    pub schema: Option<faucet_core::SchemaDriftSpec>,
66    /// Delivery guarantee for this row. Resolved from the row's override or
67    /// falls back to the top-level `cfg.delivery`.
68    pub delivery: faucet_core::DeliveryMode,
69    /// The **derived** end-to-end guarantee this row's source × sink × config
70    /// actually provides (issue #292) — computed for every row regardless of
71    /// the requested `delivery:` mode, so `faucet validate` / `doctor` report
72    /// it truthfully (e.g. a keyed-upsert row is effectively-once even when
73    /// the user did not ask for `exactly_once`).
74    pub delivery_guarantee: faucet_core::DeliveryGuarantee,
75    /// Row ids this node waits for (deduplicated, declaration order). The
76    /// executor starts the node only after every listed row's invocations
77    /// finish successfully; a failed or skipped dependency skips this node.
78    pub depends_on: Vec<String>,
79    /// Every `${id.path}` placeholder that survived load-time interpolation.
80    /// Populated by `collect_deferred`; the executor uses this to know
81    /// which parent record to feed which row.
82    pub deferred_refs: Vec<DeferredRef>,
83    /// A pre-built source that replaces the registry-built one for this node.
84    /// Set only by `faucet dlq replay` (#281), which injects a
85    /// [`DlqReaderSource`](crate::dlq_replay::reader::DlqReaderSource) so the
86    /// executor runs it through the normal pipeline path. `None` for every
87    /// config-driven node (the executor builds the source from `source.kind`).
88    pub source_override: Option<crate::dlq_replay::reader::SourceOverride>,
89}
90
91#[derive(Debug, Clone)]
92pub enum NodeRole {
93    /// Root node — runs once per pipeline invocation.
94    Root,
95    /// Child node — runs once per record produced by the parent row.
96    Child {
97        parent_id: String,
98        parent_key: String,
99    },
100}
101
102#[derive(Debug, Clone)]
103pub struct DeferredRef {
104    pub referenced_id: String,
105    pub dotted_path: String,
106    pub token: String,
107}
108
109/// In-memory lookup of source / sink templates, built once per `expand()` call.
110/// Combines named entries from `pipeline.sources` / `pipeline.sinks` with the
111/// legacy singular `pipeline.source` / `pipeline.sink` (registered as `default`).
112struct Registry<'a> {
113    sources: HashMap<&'a str, &'a ConnectorSpec>,
114    sinks: HashMap<&'a str, &'a ConnectorSpec>,
115}
116
117impl<'a> Registry<'a> {
118    fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
119        let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
120        if let Some(default) = spec.source.as_ref() {
121            sources.insert("default", default);
122        }
123        for (name, s) in spec.sources.iter() {
124            if sources.contains_key(name.as_str()) {
125                return Err(CliError::DuplicateTemplate {
126                    kind: "source",
127                    name: name.clone(),
128                });
129            }
130            sources.insert(name.as_str(), s);
131        }
132
133        let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
134        if let Some(default) = spec.sink.as_ref() {
135            if default.transforms.is_some() {
136                return Err(CliError::TransformsOnSink {
137                    name: "default".to_string(),
138                });
139            }
140            if !default.inherit_transforms {
141                return Err(CliError::InheritTransformsOnSink {
142                    name: "default".to_string(),
143                });
144            }
145            sinks.insert("default", default);
146        }
147        for (name, s) in spec.sinks.iter() {
148            if sinks.contains_key(name.as_str()) {
149                return Err(CliError::DuplicateTemplate {
150                    kind: "sink",
151                    name: name.clone(),
152                });
153            }
154            if s.transforms.is_some() {
155                return Err(CliError::TransformsOnSink { name: name.clone() });
156            }
157            if !s.inherit_transforms {
158                return Err(CliError::InheritTransformsOnSink { name: name.clone() });
159            }
160            sinks.insert(name.as_str(), s);
161        }
162        Ok(Self { sources, sinks })
163    }
164
165    fn known(&self, kind: &'static str) -> Vec<String> {
166        debug_assert!(
167            matches!(kind, "source" | "sink"),
168            "Registry::known called with kind = {:?}",
169            kind
170        );
171        let map = if kind == "source" {
172            &self.sources
173        } else {
174            &self.sinks
175        };
176        let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
177        out.sort();
178        out
179    }
180
181    fn resolve(
182        &self,
183        kind: &'static str,
184        row_id: &str,
185        overlay: Option<&PartialConnector>,
186    ) -> CliResult<ConnectorSpec> {
187        debug_assert!(
188            matches!(kind, "source" | "sink"),
189            "Registry::resolve called with kind = {:?}",
190            kind
191        );
192        let map = if kind == "source" {
193            &self.sources
194        } else {
195            &self.sinks
196        };
197        let ref_name = overlay
198            .and_then(|p| p.r#ref.as_deref())
199            .unwrap_or("default");
200        let base = map.get(ref_name).ok_or_else(|| {
201            if ref_name == "default" {
202                CliError::MissingTemplate {
203                    kind,
204                    row_id: row_id.to_owned(),
205                }
206            } else {
207                CliError::UnknownTemplate {
208                    kind,
209                    name: ref_name.to_owned(),
210                    row_id: row_id.to_owned(),
211                    known: self.known(kind),
212                }
213            }
214        })?;
215        let mut out = (*base).clone();
216        if let Some(p) = overlay {
217            if let Some(k) = &p.kind {
218                out.kind = k.clone();
219            }
220            if let Some(c) = &p.config {
221                merge_value(&mut out.config, c.clone());
222            }
223        }
224        Ok(out)
225    }
226}
227
228/// Expand `cfg` into a topologically valid list of nodes. Roots come first,
229/// then children in BFS order.
230pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
231    // Fail-fast at config load: validate the execution-level adaptive
232    // batch-size controller here (the shared `validate`/`run`/`preview`/
233    // `doctor`/`schedule` gate) so `faucet validate` rejects a bad block
234    // rather than only surfacing it mid-run in the executor.
235    if let Some(ab) = cfg
236        .execution
237        .as_ref()
238        .and_then(|e| e.adaptive_batch_size.as_ref())
239    {
240        // `validate()` returns `FaucetError::Config` whose message already names
241        // the offending field; propagate it directly (CliError: From<FaucetError>).
242        ab.validate()?;
243    }
244
245    // Implicit single-row case: empty matrix → run pipeline once with no merge.
246    let synthetic_row;
247    let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
248        synthetic_row = [MatrixRow {
249            id: None,
250            parent: None,
251            depends_on: Vec::new(),
252            parent_key: "id".into(),
253            source: None,
254            sink: None,
255            transforms: None,
256            inherit_transforms: true,
257            state: None,
258            dlq: None,
259            delivery: None,
260        }];
261        &synthetic_row
262    } else {
263        &cfg.matrix
264    };
265
266    // 1) Assign / validate ids.
267    let mut ids: Vec<String> = Vec::with_capacity(rows.len());
268    let mut seen: HashSet<String> = HashSet::new();
269    for (i, row) in rows.iter().enumerate() {
270        let id = match &row.id {
271            Some(s) => s.clone(),
272            None => format!("row-{i}"),
273        };
274        if RESERVED_IDS.contains(&id.as_str()) {
275            return Err(CliError::ReservedRowId { id });
276        }
277        if !seen.insert(id.clone()) {
278            return Err(CliError::DuplicateRowId { id });
279        }
280        ids.push(id);
281    }
282    let id_set: HashSet<&str> = ids.iter().map(String::as_str).collect();
283
284    // 2) Validate parents + detect cycles.
285    let mut parents: HashMap<&str, &str> = HashMap::new();
286    for (i, row) in rows.iter().enumerate() {
287        let id = ids[i].as_str();
288        if let Some(parent) = row.parent.as_deref() {
289            if !id_set.contains(parent) {
290                return Err(CliError::UnknownParent {
291                    id: id.to_owned(),
292                    parent: parent.to_owned(),
293                });
294            }
295            if parent == id {
296                return Err(CliError::ParentCycle {
297                    ids: vec![id.to_owned()],
298                });
299            }
300            parents.insert(id, parent);
301        }
302    }
303    detect_cycle(&parents)?;
304
305    // 2b) Validate `depends_on` edges (unknown id, self-dependency) and
306    // dedup each row's list while preserving declaration order. Then check
307    // the *combined* parent + depends_on graph for cycles — `detect_cycle`
308    // above only walks single-parent chains, so a cycle routed through a
309    // `depends_on` edge would otherwise deadlock the executor at run time.
310    let mut deps_by_row: Vec<Vec<String>> = Vec::with_capacity(rows.len());
311    for (i, row) in rows.iter().enumerate() {
312        let id = ids[i].as_str();
313        let mut deps: Vec<String> = Vec::with_capacity(row.depends_on.len());
314        for dep in &row.depends_on {
315            if !id_set.contains(dep.as_str()) {
316                return Err(CliError::UnknownDependency {
317                    id: id.to_owned(),
318                    depends_on: dep.clone(),
319                });
320            }
321            if dep == id {
322                return Err(CliError::DependencyCycle {
323                    ids: vec![id.to_owned()],
324                });
325            }
326            if !deps.contains(dep) {
327                deps.push(dep.clone());
328            }
329        }
330        deps_by_row.push(deps);
331    }
332    detect_combined_cycle(&ids, &parents, &deps_by_row)?;
333
334    // 3) Validate `${id.path}` references — each `id` must be a known row.
335    // We scan the *raw* (pre-merge) row configs because interpolation lives in
336    // strings that survive merging unchanged.
337    for (i, row) in rows.iter().enumerate() {
338        let id = ids[i].as_str();
339        if let Some(p) = &row.source
340            && let Some(c) = &p.config
341        {
342            check_refs(c, &id_set, id)?;
343        }
344        if let Some(p) = &row.sink
345            && let Some(c) = &p.config
346        {
347            check_refs(c, &id_set, id)?;
348        }
349    }
350    if let Some(s) = &cfg.pipeline.source {
351        check_refs(&s.config, &id_set, "pipeline.source")?;
352    }
353    if let Some(s) = &cfg.pipeline.sink {
354        check_refs(&s.config, &id_set, "pipeline.sink")?;
355    }
356    for (name, s) in &cfg.pipeline.sources {
357        check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
358    }
359    for (name, s) in &cfg.pipeline.sinks {
360        check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
361    }
362
363    // 4) Build template registry — validates duplicate default conflicts.
364    let registry = Registry::build(&cfg.pipeline)?;
365
366    // 5) Build expanded nodes. Order: roots first (in declaration order),
367    // then BFS over children — guarantees a parent appears before its children.
368    let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
369    let mut roots: Vec<usize> = Vec::new();
370    for (i, row) in rows.iter().enumerate() {
371        match row.parent.as_deref() {
372            None => roots.push(i),
373            Some(p) => by_parent.entry(p).or_default().push(i),
374        }
375    }
376
377    let mut order: Vec<usize> = Vec::with_capacity(rows.len());
378    let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
379    while let Some(idx) = queue.pop_front() {
380        order.push(idx);
381        if let Some(children) = by_parent.get(ids[idx].as_str()) {
382            queue.extend(children.iter().copied());
383        }
384    }
385    debug_assert_eq!(order.len(), rows.len());
386
387    let mut out = Vec::with_capacity(rows.len());
388    for &i in &order {
389        let row = &rows[i];
390        let row_id = ids[i].as_str();
391        let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
392        let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
393        // The sink template name this row resolved (or the legacy `default`),
394        // used to scope masking `applies_to` per destination.
395        let sink_ref = row
396            .sink
397            .as_ref()
398            .and_then(|s| s.r#ref.clone())
399            .unwrap_or_else(|| "default".to_string());
400        let role = match &row.parent {
401            None => NodeRole::Root,
402            Some(p) => NodeRole::Child {
403                parent_id: p.clone(),
404                parent_key: row.parent_key.clone(),
405            },
406        };
407        let mut deferred = Vec::new();
408        collect_deferred(&merged_source.config, &mut deferred);
409        collect_deferred(&merged_sink.config, &mut deferred);
410
411        // Resolve transforms, state, and DLQ (row overrides win over base).
412        // Three-layer additive resolution:
413        //   T_pipeline ++ T_source ++ T_row
414        // gated on each layer's `inherit_transforms` flag.
415        let src_inherit = merged_source.inherit_transforms;
416        let row_inherit = row.inherit_transforms;
417        let mut transforms: Vec<TransformSpec> = Vec::new();
418        if src_inherit && row_inherit {
419            transforms.extend(cfg.pipeline.transforms.iter().cloned());
420        }
421        if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
422            transforms.extend(src_ts.iter().cloned());
423        }
424        if let Some(row_ts) = row.transforms.as_ref() {
425            transforms.extend(row_ts.iter().cloned());
426        }
427        let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
428        // Row override wins; fall back to the top-level delivery mode.
429        let delivery = row.delivery.unwrap_or(cfg.delivery);
430        // Three-state match: Some(None) = disable, Some(Some(spec)) = replace,
431        // None = inherit. The naive `.flatten().or_else()` would conflate
432        // disable and absent, silently inheriting on explicit null.
433        let dlq = match row.dlq.clone() {
434            Some(None) => None,
435            Some(Some(spec)) => Some(spec),
436            None => cfg.pipeline.dlq.clone(),
437        };
438
439        if let Some(ref d) = dlq {
440            if matches!(d.max_failures_per_page, Some(0)) {
441                return Err(CliError::InvalidDlqBudget {
442                    field: "max_failures_per_page",
443                });
444            }
445            if matches!(d.max_failures_total, Some(0)) {
446                return Err(CliError::InvalidDlqBudget {
447                    field: "max_failures_total",
448                });
449            }
450            if !crate::registry::sink_exists(&d.sink.kind) {
451                return Err(CliError::UnknownDlqSinkKind {
452                    kind: d.sink.kind.clone(),
453                    context: format!("row `{row_id}`"),
454                });
455            }
456        }
457
458        // Runtime interpolation (`${row.path}` parent refs and `${now.*}`) is
459        // resolved only in source/sink configs. A token in a transform / state
460        // / dlq config would otherwise reach the connector as a literal
461        // `${...}` string with no error (#146 M2) — reject it at expand time.
462        for (ti, t) in transforms.iter().enumerate() {
463            reject_runtime_tokens(
464                &t.config,
465                &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
466            )?;
467        }
468        if let Some(ref st) = state {
469            reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
470        }
471        if let Some(ref d) = dlq {
472            reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
473        }
474
475        // `quality:` is pipeline-level only in v1 (no matrix-row override), so
476        // every node carries the same spec. Compile it once per node to (a)
477        // surface invalid paths/regexes/bounds at expand time, and (b) fail
478        // fast when a quarantine check has no DLQ to route to — the core guards
479        // this at run start too, but catching it here makes `faucet validate`
480        // a friendly, fast failure.
481        #[cfg(feature = "quality")]
482        let quality = cfg.pipeline.quality.clone();
483        #[cfg(feature = "quality")]
484        if let Some(ref spec) = quality {
485            let compiled = faucet_core::CompiledQuality::compile(spec)
486                .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
487            if compiled.requires_dlq() && dlq.is_none() {
488                return Err(CliError::Config(format!(
489                    "row `{row_id}`: a quality check uses `on_failure: quarantine` \
490                     but no DLQ is configured — add a `dlq:` block (or change the \
491                     check's `on_failure` to `abort`)"
492                )));
493            }
494        }
495
496        // `contract:` is pipeline-level only in v1 (like `quality:`). Compile
497        // it once per node so a malformed contract (bad regex, duplicate
498        // fields, misplaced constraints) surfaces at expand time, and fail
499        // fast when `on_breach: quarantine` has no DLQ to route to.
500        #[cfg(feature = "contract")]
501        let contract = cfg.pipeline.contract.clone();
502        #[cfg(feature = "contract")]
503        if let Some(ref spec) = contract {
504            let compiled = faucet_core::CompiledContract::compile(spec)
505                .map_err(|e| CliError::Config(format!("contract (row `{row_id}`): {e}")))?;
506            if compiled.requires_dlq() && dlq.is_none() {
507                return Err(CliError::Config(format!(
508                    "row `{row_id}`: the contract uses `on_breach: quarantine` \
509                     but no DLQ is configured — add a `dlq:` block (or change \
510                     `on_breach` to `fail` or `warn`)"
511                )));
512            }
513        }
514
515        // `masking:` is pipeline-level only in v1 (like `quality:`/`contract:`).
516        // Compile it once per node so a malformed policy (empty rules, empty
517        // match, bad regex) surfaces at expand time. No DLQ gate — masking
518        // never quarantines; it rewrites matching fields in place.
519        #[cfg(feature = "masking")]
520        let masking = cfg.pipeline.masking.clone();
521        #[cfg(feature = "masking")]
522        if let Some(ref spec) = masking {
523            faucet_core::CompiledMasking::compile(spec)
524                .map_err(|e| CliError::Config(format!("masking (row `{row_id}`): {e}")))?;
525        }
526
527        // Resilience poison-pill cross-check: `poison.action: dlq` routes
528        // persistently-failing rows to the DLQ, so a DLQ must be configured.
529        // Caught here so `faucet validate` reports it before any run starts.
530        if let Some(spec) = &cfg.resilience
531            && matches!(
532                spec.poison.as_ref().map(|p| p.action),
533                Some(crate::config::PoisonActionSpec::Dlq)
534            )
535            && dlq.is_none()
536        {
537            return Err(CliError::Config(format!(
538                "row '{row_id}': resilience.poison.action=dlq requires a dlq: block"
539            )));
540        }
541
542        // SLA gate (load-time, #202): validate the spec once per row and
543        // require a `state:` block when staleness / volume-anomaly checks need
544        // persisted history. `min_rows_per_run` alone is stateless and passes
545        // without one.
546        if let Some(ref sla) = cfg.sla {
547            sla.validate()
548                .map_err(|e| CliError::Config(format!("sla: {e}")))?;
549            if sla.needs_state() {
550                match state.as_ref() {
551                    None => {
552                        return Err(CliError::Config(format!(
553                            "row '{row_id}': sla.max_staleness_secs / sla.volume_anomaly \
554                             need persisted run history — add a `state:` block \
555                             (min_rows_per_run alone works without one)"
556                        )));
557                    }
558                    Some(s) if s.kind == "memory" => {
559                        tracing::warn!(
560                            row = %row_id,
561                            "sla: the `memory` state store resets on process exit — \
562                             staleness/volume baselines only persist within a single \
563                             `faucet schedule`/`serve` process; use `file`, `redis`, \
564                             or `postgres` for one-shot runs"
565                        );
566                    }
567                    Some(_) => {}
568                }
569            }
570        }
571
572        // write_mode × sink validation (load-time): reject an unsupported mode
573        // for the sink kind, and upsert/delete without a key, before any run.
574        // Runs for every row; append rows pass trivially.
575        let requested_mode = merged_sink
576            .config
577            .get("write_mode")
578            .and_then(|v| v.as_str())
579            .unwrap_or("append");
580        let mode = match requested_mode {
581            "append" => faucet_core::WriteMode::Append,
582            "upsert" => faucet_core::WriteMode::Upsert,
583            "delete" => faucet_core::WriteMode::Delete,
584            other => {
585                return Err(CliError::Config(format!(
586                    "row '{}': unknown write_mode '{}' (expected append, upsert, or delete)",
587                    ids[i], other
588                )));
589            }
590        };
591        if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
592            return Err(CliError::Config(format!(
593                "row '{}': write_mode '{}' is not supported by sink '{}' \
594                 (upsert/delete sinks: {})",
595                ids[i],
596                requested_mode,
597                merged_sink.kind,
598                crate::registry::UPSERT_SINK_KINDS.join(", ")
599            )));
600        }
601        if matches!(
602            mode,
603            faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
604        ) {
605            let key_present = merged_sink
606                .config
607                .get("key")
608                .and_then(|v| v.as_array())
609                .map(|a| !a.is_empty())
610                .unwrap_or(false);
611            if !key_present {
612                return Err(CliError::Config(format!(
613                    "row '{}': write_mode '{}' requires a non-empty `key`",
614                    ids[i], requested_mode
615                )));
616            }
617        }
618
619        // Derived end-to-end delivery guarantee (issue #292): computed for
620        // *every* row — regardless of the requested `delivery:` mode — so
621        // `faucet validate` / `doctor` report the truth (a keyed-upsert row is
622        // effectively-once even when the user did not request `exactly_once`).
623        // `keyed_upsert_configured` relies on the write_mode gate above: after
624        // it, an upsert/delete mode implies a non-empty `key`.
625        let keyed_upsert_configured = matches!(
626            mode,
627            faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
628        );
629        let guarantee_inputs = faucet_core::GuaranteeInputs {
630            replay: crate::registry::source_replay_guarantee(&merged_source.kind),
631            sink_atomic: crate::registry::sink_supports_idempotent_writes(&merged_sink.kind),
632            keyed_upsert_configured,
633            durable_state: matches!(state.as_ref(), Some(s) if s.kind != "memory"),
634            dlq: dlq.is_some(),
635        };
636        let delivery_guarantee = faucet_core::derive_delivery_guarantee(&guarantee_inputs);
637
638        // Exactly-once delivery gate: `delivery: exactly_once` means "require
639        // ≥ effectively-once". Enforced at config-load time so `faucet
640        // validate` catches an unsupported topology before any run starts,
641        // with the error naming the limiting side. A derived `AtLeastOnce`
642        // implies keyed dedup is not configured (the keyed mechanism has no
643        // other requirement), so the cascade below walks the atomic-watermark
644        // requirements in order.
645        if delivery == faucet_core::DeliveryMode::ExactlyOnce
646            && delivery_guarantee == faucet_core::DeliveryGuarantee::AtLeastOnce
647        {
648            if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
649                let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
650                {
651                    format!(
652                        ", or configure `write_mode: upsert` + `key` on sink '{}' for \
653                         keyed-upsert effectively-once with any source",
654                        merged_sink.kind
655                    )
656                } else {
657                    String::new()
658                };
659                return Err(CliError::Config(format!(
660                    "row '{}': delivery: exactly_once is not supported by source '{}' \
661                     (deterministic-replay sources only: {}{})",
662                    ids[i],
663                    merged_source.kind,
664                    crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", "),
665                    keyed_hint
666                )));
667            }
668            if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
669                let keyed_hint = if crate::registry::UPSERT_SINK_KINDS.contains(&&*merged_sink.kind)
670                {
671                    format!(
672                        "; alternatively configure `write_mode: upsert` + `key` on '{}' for \
673                         keyed-upsert effectively-once",
674                        merged_sink.kind
675                    )
676                } else {
677                    String::new()
678                };
679                return Err(CliError::Config(format!(
680                    "row '{}': delivery: exactly_once is not supported by sink '{}' \
681                     (idempotent sinks only: {}{})",
682                    ids[i],
683                    merged_sink.kind,
684                    crate::registry::IDEMPOTENT_SINK_KINDS.join(", "),
685                    keyed_hint
686                )));
687            }
688            // Require a *durable* state store. The atomic-watermark mechanism
689            // persists the monotonic page sequence alongside the bookmark
690            // (`wrap_state(bookmark, seq)`) and resumes from it across
691            // restarts; the in-process `memory` store loses that watermark on
692            // exit, so a restart would re-run already-committed pages — exactly
693            // the duplication exactly-once exists to prevent (F24). Mirror the
694            // `faucet replicate` gate, which already rejects `memory`.
695            match state.as_ref() {
696                None => {
697                    return Err(CliError::Config(format!(
698                        "row '{}': delivery: exactly_once requires a state store",
699                        ids[i]
700                    )));
701                }
702                Some(s) if s.kind == "memory" => {
703                    return Err(CliError::Config(format!(
704                        "row '{}': delivery: exactly_once requires a durable state store, \
705                         not `memory` — the cross-restart watermark/sequence guarantee \
706                         depends on it (use `file`, `redis`, or `postgres`)",
707                        ids[i]
708                    )));
709                }
710                Some(_) => {}
711            }
712            if dlq.is_some() {
713                return Err(CliError::Config(format!(
714                    "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
715                    ids[i]
716                )));
717            }
718            // The cascade above covers every way the derivation can land on
719            // at-least-once; reaching here would mean it diverged from the
720            // checks.
721            unreachable!("delivery-guarantee derivation and the exactly-once gate diverged");
722        }
723
724        // Schema-drift policy gates (load-time):
725        //  - `evolve` requires an evolution-capable sink.
726        //  - `quarantine` (drift or incompatible) requires a DLQ, and is
727        //    incompatible with exactly-once (which forbids a DLQ).
728        if let Some(ref sd) = cfg.pipeline.schema {
729            let policy = faucet_core::SchemaDriftPolicy::compile(sd);
730            if policy.on_drift == faucet_core::OnDrift::Evolve
731                && !crate::registry::sink_supports_schema_evolution(&merged_sink.kind)
732            {
733                return Err(CliError::Config(format!(
734                    "row '{}': schema.on_drift: evolve is not supported by sink '{}' \
735                     (evolvable sinks: postgres, mysql, mssql, sqlite, bigquery, elasticsearch)",
736                    ids[i], merged_sink.kind
737                )));
738            }
739            if policy.requires_dlq() && dlq.is_none() {
740                return Err(CliError::Config(format!(
741                    "row '{}': schema.on_drift/on_incompatible 'quarantine' requires a `dlq:` block",
742                    ids[i]
743                )));
744            }
745            if policy.requires_dlq() && delivery == faucet_core::DeliveryMode::ExactlyOnce {
746                return Err(CliError::Config(format!(
747                    "row '{}': schema quarantine is incompatible with delivery: exactly_once \
748                     (exactly_once forbids a DLQ)",
749                    ids[i]
750                )));
751            }
752        }
753
754        out.push(ExpandedNode {
755            id: ids[i].clone(),
756            row_index: i,
757            role,
758            source: merged_source,
759            sink: merged_sink,
760            transforms,
761            state,
762            dlq,
763            delivery,
764            delivery_guarantee,
765            #[cfg(feature = "quality")]
766            quality,
767            #[cfg(feature = "contract")]
768            contract,
769            #[cfg(feature = "masking")]
770            masking,
771            sink_ref,
772            schema: cfg.pipeline.schema.clone(),
773            depends_on: deps_by_row[i].clone(),
774            deferred_refs: deferred,
775            source_override: None,
776        });
777    }
778    Ok(out)
779}
780
781fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
782    // Each node has at most one parent ⇒ cycle detection is "walk parents
783    // until we hit `None` or revisit a node we've already seen this walk".
784    for &start in parents.keys() {
785        let mut visited: BTreeSet<&str> = BTreeSet::new();
786        let mut cur = start;
787        while let Some(&p) = parents.get(cur) {
788            if !visited.insert(cur) {
789                let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
790                return Err(CliError::ParentCycle { ids: chain });
791            }
792            cur = p;
793            if cur == start {
794                let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
795                chain.push(start.to_string());
796                return Err(CliError::ParentCycle { ids: chain });
797            }
798        }
799    }
800    Ok(())
801}
802
803/// Kahn's algorithm over the combined `parent:` + `depends_on:` edge set.
804/// Pure-parent cycles are already caught by [`detect_cycle`] (with its more
805/// specific error), so any leftover here necessarily involves a `depends_on`
806/// edge. Rows that cannot be topologically ordered are the cycle participants
807/// (plus any rows downstream of them — still actionable, since the report
808/// names every row that would never become ready).
809fn detect_combined_cycle(
810    ids: &[String],
811    parents: &HashMap<&str, &str>,
812    deps_by_row: &[Vec<String>],
813) -> CliResult<()> {
814    let index_of: HashMap<&str, usize> = ids
815        .iter()
816        .enumerate()
817        .map(|(i, id)| (id.as_str(), i))
818        .collect();
819    let mut in_degree = vec![0usize; ids.len()];
820    let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); ids.len()];
821    for (i, id) in ids.iter().enumerate() {
822        let mut prereqs: Vec<usize> = Vec::new();
823        if let Some(p) = parents.get(id.as_str()) {
824            prereqs.push(index_of[p]);
825        }
826        prereqs.extend(deps_by_row[i].iter().map(|d| index_of[d.as_str()]));
827        for p in prereqs {
828            in_degree[i] += 1;
829            dependents[p].push(i);
830        }
831    }
832    let mut queue: std::collections::VecDeque<usize> =
833        (0..ids.len()).filter(|&i| in_degree[i] == 0).collect();
834    let mut processed = 0usize;
835    while let Some(i) = queue.pop_front() {
836        processed += 1;
837        for &d in &dependents[i] {
838            in_degree[d] -= 1;
839            if in_degree[d] == 0 {
840                queue.push_back(d);
841            }
842        }
843    }
844    if processed < ids.len() {
845        let mut stuck: Vec<String> = (0..ids.len())
846            .filter(|&i| in_degree[i] > 0)
847            .map(|i| ids[i].clone())
848            .collect();
849        stuck.sort();
850        return Err(CliError::DependencyCycle { ids: stuck });
851    }
852    Ok(())
853}
854
855/// Verify that every `${X.path}` token in `value` has `X` listed in `id_set`.
856/// Load-time prefixes (`env`, `file`, `secret`) were already handled and are
857/// ignored here.
858fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
859    walk_strings(value, &mut |s| {
860        for (token, dir) in iter_directives(s) {
861            // Load-time / template directives (`${env:..}`, `${vars.X}`, …) are
862            // resolved before expansion; only deferred `${id.path}` references
863            // are validated here, against the known row ids.
864            // `now` and `backfill` are reserved built-in deferred ids
865            // resolved at run time (`backfill` by `faucet backfill`, #282).
866            if let Directive::Deferred { id, .. } = dir
867                && id != "now"
868                && id != "backfill"
869                && !id_set.contains(id)
870            {
871                return Err(CliError::UnknownInterpolationId {
872                    id: id.to_owned(),
873                    token: format!("{token} (in {owner})"),
874                });
875            }
876        }
877        Ok(())
878    })
879}
880
881/// Reject any runtime interpolation token (`${id.path}` parent-record refs and
882/// `${now.*}`) found in `value`. These resolve **only** in source/sink configs;
883/// elsewhere — transform / state / dlq bodies — they would silently reach the
884/// connector as a literal `${...}` string (#146 M2). Load-time directives
885/// (`${env:}`, `${vars.X}`, `${sources.X}`, …) are already resolved before
886/// expansion, so any deferred token still present here is genuinely
887/// unsupported in this location.
888fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
889    walk_strings(value, &mut |s| {
890        for (token, dir) in iter_directives(s) {
891            if let Directive::Deferred { .. } = dir {
892                return Err(CliError::Config(format!(
893                    "interpolation token `{token}` in {location} is not supported: \
894                     `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
895                     resolve only in source/sink configs"
896                )));
897            }
898        }
899        Ok(())
900    })
901}
902
903fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
904    let _ = walk_strings(value, &mut |s| {
905        for (token, dir) in iter_directives(s) {
906            if let Directive::Deferred { id, path } = dir {
907                // `now` / `backfill` are reserved built-ins resolved at run
908                // time, not parent-record dependencies — skip them so the
909                // executor doesn't treat them as deferred parent-record refs.
910                if id == "now" || id == "backfill" {
911                    continue;
912                }
913                out.push(DeferredRef {
914                    referenced_id: id.to_owned(),
915                    dotted_path: path.to_owned(),
916                    token: token.to_owned(),
917                });
918            }
919        }
920        Ok(())
921    });
922}
923
924fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
925where
926    F: FnMut(&str) -> CliResult<()>,
927{
928    match value {
929        Value::String(s) => f(s),
930        Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
931        Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
932        _ => Ok(()),
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use crate::config::{OnBatchErrorSpec, parse_with_extension};
940
941    fn cfg(yaml: &str) -> PipelineConfig {
942        parse_with_extension(yaml, "yaml").unwrap()
943    }
944
945    #[test]
946    fn implicit_single_row_when_matrix_absent() {
947        let c = cfg(r#"
948version: 1
949pipeline:
950  source: { type: rest, config: { base_url: https://x } }
951  sink:   { type: jsonl, config: { path: ./o } }
952"#);
953        let nodes = expand(&c).unwrap();
954        assert_eq!(nodes.len(), 1);
955        assert_eq!(nodes[0].id, "row-0");
956        assert!(matches!(nodes[0].role, NodeRole::Root));
957        assert_eq!(nodes[0].source.kind, "rest");
958        assert_eq!(nodes[0].sink.kind, "jsonl");
959    }
960
961    #[test]
962    fn rejects_runtime_token_in_dlq_config() {
963        // M2 (#146): `${now.*}` / `${parent.path}` resolve only in source/sink
964        // configs. In a dlq config they would silently pass through as a literal
965        // `${...}` string — expand must reject them with a clear error.
966        let c = cfg(r#"
967version: 1
968pipeline:
969  source: { type: rest, config: { base_url: https://x } }
970  sink:   { type: jsonl, config: { path: ./o } }
971  dlq:
972    sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
973"#);
974        let err = expand(&c).unwrap_err();
975        assert!(
976            matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
977            "got: {err:?}"
978        );
979    }
980
981    #[test]
982    fn rejects_runtime_token_in_state_config() {
983        let c = cfg(r#"
984version: 1
985pipeline:
986  source: { type: rest, config: { base_url: https://x } }
987  sink:   { type: jsonl, config: { path: ./o } }
988  state:
989    type: file
990    config: { path: "state-${now.date}" }
991"#);
992        let err = expand(&c).unwrap_err();
993        assert!(
994            matches!(&err, CliError::Config(m) if m.contains("state")),
995            "got: {err:?}"
996        );
997    }
998
999    #[test]
1000    fn rejects_runtime_token_in_transform_config() {
1001        let c = cfg(r#"
1002version: 1
1003pipeline:
1004  source: { type: rest, config: { base_url: https://x } }
1005  sink:   { type: jsonl, config: { path: ./o } }
1006  transforms:
1007    - type: set
1008      config: { field: ts, value: "${now.datetime}" }
1009"#);
1010        let err = expand(&c).unwrap_err();
1011        assert!(
1012            matches!(&err, CliError::Config(m) if m.contains("transform")),
1013            "got: {err:?}"
1014        );
1015    }
1016
1017    #[test]
1018    fn allows_runtime_token_in_source_and_sink_configs() {
1019        // The same tokens remain valid in source/sink configs (regression guard
1020        // that M2's rejection didn't over-reach).
1021        let c = cfg(r#"
1022version: 1
1023pipeline:
1024  source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
1025  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1026"#);
1027        let nodes = expand(&c).unwrap();
1028        assert_eq!(nodes.len(), 1);
1029    }
1030
1031    #[test]
1032    fn merges_row_overrides_into_pipeline_source() {
1033        let c = cfg(r#"
1034version: 1
1035pipeline:
1036  source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
1037  sink:   { type: jsonl, config: { path: ./o } }
1038matrix:
1039  - id: users
1040    source: { config: { path: /v1/users, headers: { b: 2 } } }
1041"#);
1042        let nodes = expand(&c).unwrap();
1043        assert_eq!(nodes[0].id, "users");
1044        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1045        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1046        assert_eq!(nodes[0].source.config["headers"]["a"], 1);
1047        assert_eq!(nodes[0].source.config["headers"]["b"], 2);
1048    }
1049
1050    #[test]
1051    fn errors_on_unknown_parent() {
1052        let c = cfg(r#"
1053version: 1
1054pipeline:
1055  source: { type: rest, config: {} }
1056  sink:   { type: jsonl, config: { path: ./o } }
1057matrix:
1058  - id: child
1059    parent: nobody
1060"#);
1061        assert!(matches!(
1062            expand(&c).unwrap_err(),
1063            CliError::UnknownParent { .. }
1064        ));
1065    }
1066
1067    #[test]
1068    fn errors_on_duplicate_ids() {
1069        let c = cfg(r#"
1070version: 1
1071pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1072matrix:
1073  - { id: x }
1074  - { id: x }
1075"#);
1076        assert!(matches!(
1077            expand(&c).unwrap_err(),
1078            CliError::DuplicateRowId { .. }
1079        ));
1080    }
1081
1082    #[test]
1083    fn errors_on_reserved_id() {
1084        let c = cfg(r#"
1085version: 1
1086pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1087matrix:
1088  - { id: env }
1089"#);
1090        assert!(matches!(
1091            expand(&c).unwrap_err(),
1092            CliError::ReservedRowId { .. }
1093        ));
1094    }
1095
1096    #[test]
1097    fn errors_on_self_parent_cycle() {
1098        let c = cfg(r#"
1099version: 1
1100pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1101matrix:
1102  - { id: a, parent: a }
1103"#);
1104        assert!(matches!(
1105            expand(&c).unwrap_err(),
1106            CliError::ParentCycle { .. }
1107        ));
1108    }
1109
1110    #[test]
1111    fn errors_on_two_node_cycle() {
1112        let c = cfg(r#"
1113version: 1
1114pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1115matrix:
1116  - { id: a, parent: b }
1117  - { id: b, parent: a }
1118"#);
1119        assert!(matches!(
1120            expand(&c).unwrap_err(),
1121            CliError::ParentCycle { .. }
1122        ));
1123    }
1124
1125    #[test]
1126    fn errors_on_unknown_dependency() {
1127        let c = cfg(r#"
1128version: 1
1129pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1130matrix:
1131  - { id: facts, depends_on: [nobody] }
1132"#);
1133        match expand(&c).unwrap_err() {
1134            CliError::UnknownDependency { id, depends_on } => {
1135                assert_eq!(id, "facts");
1136                assert_eq!(depends_on, "nobody");
1137            }
1138            other => panic!("expected UnknownDependency, got {other:?}"),
1139        }
1140    }
1141
1142    #[test]
1143    fn errors_on_self_dependency() {
1144        let c = cfg(r#"
1145version: 1
1146pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1147matrix:
1148  - { id: a, depends_on: [a] }
1149"#);
1150        match expand(&c).unwrap_err() {
1151            CliError::DependencyCycle { ids } => assert_eq!(ids, vec!["a".to_string()]),
1152            other => panic!("expected DependencyCycle, got {other:?}"),
1153        }
1154    }
1155
1156    #[test]
1157    fn errors_on_depends_on_cycle() {
1158        let c = cfg(r#"
1159version: 1
1160pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1161matrix:
1162  - { id: a, depends_on: [b] }
1163  - { id: b, depends_on: [a] }
1164"#);
1165        match expand(&c).unwrap_err() {
1166            CliError::DependencyCycle { ids } => {
1167                assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1168            }
1169            other => panic!("expected DependencyCycle, got {other:?}"),
1170        }
1171    }
1172
1173    #[test]
1174    fn errors_on_mixed_parent_depends_on_cycle() {
1175        // `a` is a child of `b` (parent edge b -> a) while `b` waits for `a`
1176        // (dependency edge a -> b). Neither the parent-only walk nor a
1177        // depends_on-only check sees this — only the combined graph does.
1178        let c = cfg(r#"
1179version: 1
1180pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1181matrix:
1182  - { id: a, parent: b }
1183  - { id: b, depends_on: [a] }
1184"#);
1185        match expand(&c).unwrap_err() {
1186            CliError::DependencyCycle { ids } => {
1187                assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1188            }
1189            other => panic!("expected DependencyCycle, got {other:?}"),
1190        }
1191    }
1192
1193    #[test]
1194    fn depends_on_is_recorded_and_deduped() {
1195        let c = cfg(r#"
1196version: 1
1197pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1198matrix:
1199  - { id: dims }
1200  - { id: staging }
1201  - { id: facts, depends_on: [dims, staging, dims] }
1202"#);
1203        let nodes = expand(&c).unwrap();
1204        let facts = nodes.iter().find(|n| n.id == "facts").unwrap();
1205        assert_eq!(
1206            facts.depends_on,
1207            vec!["dims".to_string(), "staging".to_string()]
1208        );
1209        assert!(matches!(facts.role, NodeRole::Root));
1210        let dims = nodes.iter().find(|n| n.id == "dims").unwrap();
1211        assert!(dims.depends_on.is_empty());
1212    }
1213
1214    #[test]
1215    fn depends_on_may_target_a_child_row() {
1216        // Waiting on a per-record fan-out row is legal: the dependent starts
1217        // only after every one of the child's invocations completes.
1218        let c = cfg(r#"
1219version: 1
1220pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1221matrix:
1222  - { id: users }
1223  - { id: posts, parent: users }
1224  - { id: rollup, depends_on: [posts] }
1225"#);
1226        let nodes = expand(&c).unwrap();
1227        let rollup = nodes.iter().find(|n| n.id == "rollup").unwrap();
1228        assert_eq!(rollup.depends_on, vec!["posts".to_string()]);
1229    }
1230
1231    #[test]
1232    fn errors_on_unknown_interpolation_id() {
1233        let c = cfg(r#"
1234version: 1
1235pipeline:
1236  source: { type: rest, config: { url: "https://x/${nobody.id}" } }
1237  sink:   { type: jsonl, config: { path: ./o } }
1238"#);
1239        assert!(matches!(
1240            expand(&c).unwrap_err(),
1241            CliError::UnknownInterpolationId { .. }
1242        ));
1243    }
1244
1245    #[test]
1246    fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
1247        // Regression for #78/#39: `${env.foo}` has no colon, so it is a
1248        // deferred reference to id `env`, not a load-time `env:` directive.
1249        // The validator must reject it (as the runtime would), rather than
1250        // silently skipping it and letting `run` fail later.
1251        let c = cfg(r#"
1252version: 1
1253pipeline:
1254  source: { type: rest, config: { url: "https://x/${env.foo}" } }
1255  sink:   { type: jsonl, config: { path: ./o } }
1256"#);
1257        match expand(&c).unwrap_err() {
1258            CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
1259            other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
1260        }
1261    }
1262
1263    #[test]
1264    fn accepts_id_path_when_referenced_row_exists() {
1265        let c = cfg(r#"
1266version: 1
1267pipeline:
1268  source: { type: rest, config: {} }
1269  sink:   { type: jsonl, config: { path: ./o } }
1270matrix:
1271  - id: users
1272  - id: posts
1273    parent: users
1274    source: { config: { path: "/v1/users/${users.id}/posts" } }
1275"#);
1276        let nodes = expand(&c).unwrap();
1277        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1278        assert_eq!(posts.deferred_refs.len(), 1);
1279        assert_eq!(posts.deferred_refs[0].referenced_id, "users");
1280        assert_eq!(posts.deferred_refs[0].dotted_path, "id");
1281    }
1282
1283    #[test]
1284    fn nested_referenced_path_resolves() {
1285        let c = cfg(r#"
1286version: 1
1287pipeline:
1288  source: { type: rest, config: {} }
1289  sink:   { type: jsonl, config: { path: ./o } }
1290matrix:
1291  - id: users
1292  - id: addrs
1293    parent: users
1294    source: { config: { path: "/users/${users.addr.city}/addr" } }
1295"#);
1296        let nodes = expand(&c).unwrap();
1297        let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
1298        assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
1299    }
1300
1301    #[test]
1302    fn roots_come_before_children_in_order() {
1303        let c = cfg(r#"
1304version: 1
1305pipeline:
1306  source: { type: rest, config: {} }
1307  sink:   { type: jsonl, config: { path: ./o } }
1308matrix:
1309  - id: posts
1310    parent: users
1311  - id: users
1312"#);
1313        let nodes = expand(&c).unwrap();
1314        let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
1315        let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
1316        assert!(users_idx < posts_idx, "users must precede posts");
1317    }
1318
1319    #[test]
1320    fn child_node_has_parent_role() {
1321        let c = cfg(r#"
1322version: 1
1323pipeline:
1324  source: { type: rest, config: {} }
1325  sink:   { type: jsonl, config: { path: ./o } }
1326matrix:
1327  - id: users
1328  - id: posts
1329    parent: users
1330    parent_key: user_id
1331"#);
1332        let nodes = expand(&c).unwrap();
1333        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1334        match &posts.role {
1335            NodeRole::Child {
1336                parent_id,
1337                parent_key,
1338            } => {
1339                assert_eq!(parent_id, "users");
1340                assert_eq!(parent_key, "user_id");
1341            }
1342            other => panic!("expected Child, got {other:?}"),
1343        }
1344    }
1345
1346    #[test]
1347    fn expand_rejects_zero_per_page_budget() {
1348        let yaml = r#"
1349version: 1
1350pipeline:
1351  source: { type: rest, config: {} }
1352  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1353  dlq:
1354    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1355    max_failures_per_page: 0
1356"#;
1357        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1358        let err = expand(&cfg).unwrap_err();
1359        assert!(matches!(
1360            err,
1361            CliError::InvalidDlqBudget {
1362                field: "max_failures_per_page"
1363            }
1364        ));
1365    }
1366
1367    #[test]
1368    fn expand_rejects_zero_total_budget() {
1369        let yaml = r#"
1370version: 1
1371pipeline:
1372  source: { type: rest, config: {} }
1373  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1374  dlq:
1375    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1376    max_failures_total: 0
1377"#;
1378        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1379        let err = expand(&cfg).unwrap_err();
1380        assert!(matches!(
1381            err,
1382            CliError::InvalidDlqBudget {
1383                field: "max_failures_total"
1384            }
1385        ));
1386    }
1387
1388    #[test]
1389    fn expand_rejects_unknown_dlq_sink_kind() {
1390        let yaml = r#"
1391version: 1
1392pipeline:
1393  source: { type: rest, config: {} }
1394  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1395  dlq:
1396    sink: { type: not_a_sink, config: {} }
1397"#;
1398        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1399        let err = expand(&cfg).unwrap_err();
1400        assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
1401    }
1402
1403    #[cfg(feature = "quality")]
1404    #[test]
1405    fn expand_rejects_quarantine_without_dlq() {
1406        // A quality check with `on_failure: quarantine` needs a DLQ to route to.
1407        // `expand` must reject the config so `faucet validate` fails fast.
1408        let yaml = r#"
1409version: 1
1410pipeline:
1411  source: { type: rest, config: {} }
1412  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1413  quality:
1414    record:
1415      - { type: not_null, field: id, on_failure: quarantine }
1416"#;
1417        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1418        let err = expand(&cfg).unwrap_err();
1419        match err {
1420            CliError::Config(msg) => {
1421                assert!(msg.contains("quarantine"), "{msg}");
1422                assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
1423            }
1424            other => panic!("expected Config error, got {other:?}"),
1425        }
1426    }
1427
1428    #[cfg(feature = "quality")]
1429    #[test]
1430    fn expand_accepts_quarantine_with_dlq() {
1431        let yaml = r#"
1432version: 1
1433pipeline:
1434  source: { type: rest, config: {} }
1435  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1436  dlq:
1437    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1438  quality:
1439    record:
1440      - { type: not_null, field: id, on_failure: quarantine }
1441"#;
1442        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1443        let nodes = expand(&cfg).unwrap();
1444        assert_eq!(nodes.len(), 1);
1445        let q = nodes[0]
1446            .quality
1447            .as_ref()
1448            .expect("quality threaded onto node");
1449        assert_eq!(q.record.len(), 1);
1450    }
1451
1452    #[cfg(feature = "quality")]
1453    #[test]
1454    fn expand_accepts_abort_quality_without_dlq() {
1455        // `on_failure: abort` does not route to a DLQ, so no DLQ is required.
1456        let yaml = r#"
1457version: 1
1458pipeline:
1459  source: { type: rest, config: {} }
1460  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1461  quality:
1462    record:
1463      - { type: not_null, field: id, on_failure: abort }
1464"#;
1465        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1466        let nodes = expand(&cfg).unwrap();
1467        assert!(nodes[0].quality.is_some());
1468    }
1469
1470    #[cfg(feature = "contract")]
1471    #[test]
1472    fn expand_rejects_contract_quarantine_without_dlq() {
1473        let yaml = r#"
1474version: 1
1475pipeline:
1476  source: { type: rest, config: {} }
1477  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1478  contract:
1479    version: "1.0.0"
1480    on_breach: quarantine
1481    fields:
1482      - { name: id, type: integer }
1483"#;
1484        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1485        let err = expand(&cfg).unwrap_err();
1486        match err {
1487            CliError::Config(msg) => {
1488                assert!(msg.contains("on_breach: quarantine"), "{msg}");
1489                assert!(msg.contains("dlq"), "{msg}");
1490            }
1491            other => panic!("expected Config error, got {other:?}"),
1492        }
1493    }
1494
1495    #[cfg(feature = "contract")]
1496    #[test]
1497    fn expand_accepts_contract_quarantine_with_dlq() {
1498        let yaml = r#"
1499version: 1
1500pipeline:
1501  source: { type: rest, config: {} }
1502  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1503  dlq:
1504    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1505  contract:
1506    version: "1.0.0"
1507    on_breach: quarantine
1508    fields:
1509      - { name: id, type: integer }
1510"#;
1511        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1512        let nodes = expand(&cfg).unwrap();
1513        assert_eq!(nodes.len(), 1);
1514        let c = nodes[0]
1515            .contract
1516            .as_ref()
1517            .expect("contract threaded onto node");
1518        assert_eq!(c.version, "1.0.0");
1519        assert_eq!(c.fields.len(), 1);
1520    }
1521
1522    #[cfg(feature = "contract")]
1523    #[test]
1524    fn expand_accepts_contract_fail_without_dlq() {
1525        // `on_breach: fail` (the default) does not route to a DLQ.
1526        let yaml = r#"
1527version: 1
1528pipeline:
1529  source: { type: rest, config: {} }
1530  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1531  contract:
1532    version: "1.0.0"
1533    fields:
1534      - { name: id, type: integer }
1535"#;
1536        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1537        let nodes = expand(&cfg).unwrap();
1538        assert!(nodes[0].contract.is_some());
1539    }
1540
1541    #[cfg(feature = "contract")]
1542    #[test]
1543    fn expand_rejects_malformed_contract() {
1544        // A bad regex must surface at expand time (load-time), not mid-run.
1545        let yaml = r#"
1546version: 1
1547pipeline:
1548  source: { type: rest, config: {} }
1549  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1550  contract:
1551    version: "1.0.0"
1552    fields:
1553      - { name: email, type: string, pattern: "[invalid" }
1554"#;
1555        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1556        let err = expand(&cfg).unwrap_err();
1557        match err {
1558            CliError::Config(msg) => assert!(msg.contains("invalid pattern"), "{msg}"),
1559            other => panic!("expected Config error, got {other:?}"),
1560        }
1561    }
1562
1563    #[test]
1564    fn legacy_singular_source_resolves_as_default_template() {
1565        let c = cfg(r#"
1566version: 1
1567pipeline:
1568  source: { type: rest, config: { base_url: https://x } }
1569  sink:   { type: jsonl, config: { path: ./o } }
1570"#);
1571        let nodes = expand(&c).unwrap();
1572        assert_eq!(nodes[0].source.kind, "rest");
1573        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1574    }
1575
1576    #[test]
1577    fn row_with_ref_picks_named_template() {
1578        let c = cfg(r#"
1579version: 1
1580pipeline:
1581  sources:
1582    users_api: { type: rest, config: { base_url: https://x } }
1583  sinks:
1584    archive:   { type: jsonl, config: { path: ./out } }
1585matrix:
1586  - id: load_users
1587    source:
1588      ref: users_api
1589      config: { path: /v1/users }
1590    sink:
1591      ref: archive
1592      config: { path: ./users.jsonl }
1593"#);
1594        let nodes = expand(&c).unwrap();
1595        assert_eq!(nodes[0].source.kind, "rest");
1596        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1597        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1598        assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1599    }
1600
1601    #[test]
1602    fn row_without_ref_falls_back_to_default_template() {
1603        let c = cfg(r#"
1604version: 1
1605pipeline:
1606  source: { type: rest, config: { base_url: https://x } }
1607  sink:   { type: jsonl, config: { path: ./o } }
1608matrix:
1609  - id: users
1610    source: { config: { path: /v1/users } }
1611"#);
1612        let nodes = expand(&c).unwrap();
1613        assert_eq!(nodes[0].source.kind, "rest");
1614        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1615    }
1616
1617    #[test]
1618    fn unknown_template_ref_errors_with_known_list() {
1619        let c = cfg(r#"
1620version: 1
1621pipeline:
1622  sources:
1623    a: { type: rest, config: {} }
1624    b: { type: rest, config: {} }
1625  sinks:
1626    s: { type: jsonl, config: { path: ./o } }
1627matrix:
1628  - id: x
1629    source: { ref: c }
1630    sink: { ref: s }
1631"#);
1632        let err = expand(&c).unwrap_err();
1633        match err {
1634            CliError::UnknownTemplate {
1635                kind,
1636                name,
1637                row_id,
1638                known,
1639            } => {
1640                assert_eq!(kind, "source");
1641                assert_eq!(name, "c");
1642                assert_eq!(row_id, "x");
1643                assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1644            }
1645            other => panic!("expected UnknownTemplate, got {other:?}"),
1646        }
1647    }
1648
1649    #[test]
1650    fn missing_default_template_errors() {
1651        // No singular `source:` and no `sources.default` — a row without a ref
1652        // has nowhere to go.
1653        let c = cfg(r#"
1654version: 1
1655pipeline:
1656  sources:
1657    users_api: { type: rest, config: {} }
1658  sink: { type: jsonl, config: { path: ./o } }
1659matrix:
1660  - id: x
1661    source: { config: { path: /v1 } }
1662"#);
1663        let err = expand(&c).unwrap_err();
1664        match err {
1665            CliError::MissingTemplate { kind, row_id } => {
1666                assert_eq!(kind, "source");
1667                assert_eq!(row_id, "x");
1668            }
1669            other => panic!("expected MissingTemplate, got {other:?}"),
1670        }
1671    }
1672
1673    #[test]
1674    fn duplicate_default_template_errors() {
1675        // Defining both legacy `source:` and `sources.default:` is a conflict.
1676        let c = cfg(r#"
1677version: 1
1678pipeline:
1679  source: { type: rest, config: {} }
1680  sources:
1681    default: { type: rest, config: {} }
1682  sink: { type: jsonl, config: { path: ./o } }
1683"#);
1684        let err = expand(&c).unwrap_err();
1685        match err {
1686            CliError::DuplicateTemplate { kind, name } => {
1687                assert_eq!(kind, "source");
1688                assert_eq!(name, "default");
1689            }
1690            other => panic!("expected DuplicateTemplate, got {other:?}"),
1691        }
1692    }
1693
1694    #[test]
1695    fn row_can_override_template_kind() {
1696        let c = cfg(r#"
1697version: 1
1698pipeline:
1699  sources:
1700    api: { type: rest, config: { base_url: https://x } }
1701  sinks:
1702    out: { type: jsonl, config: { path: ./o } }
1703matrix:
1704  - id: x
1705    source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1706    sink: { ref: out }
1707"#);
1708        let nodes = expand(&c).unwrap();
1709        assert_eq!(nodes[0].source.kind, "graphql");
1710        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1711        assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1712    }
1713
1714    #[test]
1715    fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1716        let yaml = r#"
1717version: 1
1718pipeline:
1719  source: { type: rest, config: {} }
1720  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1721  dlq:
1722    sink: { type: jsonl, config: { path: ./base.jsonl } }
1723matrix:
1724  - id: a
1725  - id: b
1726    dlq: null
1727  - id: c
1728    dlq:
1729      sink: { type: jsonl, config: { path: ./c.jsonl } }
1730      on_batch_error: dlq_all
1731"#;
1732        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1733        let nodes = expand(&cfg).unwrap();
1734        assert_eq!(nodes.len(), 3);
1735        // Row a inherits.
1736        assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1737        assert_eq!(
1738            nodes[0]
1739                .dlq
1740                .as_ref()
1741                .unwrap()
1742                .sink
1743                .config
1744                .get("path")
1745                .unwrap(),
1746            "./base.jsonl"
1747        );
1748        // Row b is disabled.
1749        assert!(nodes[1].dlq.is_none());
1750        // Row c is replaced.
1751        assert_eq!(
1752            nodes[2].dlq.as_ref().unwrap().on_batch_error,
1753            OnBatchErrorSpec::DlqAll
1754        );
1755        assert_eq!(
1756            nodes[2]
1757                .dlq
1758                .as_ref()
1759                .unwrap()
1760                .sink
1761                .config
1762                .get("path")
1763                .unwrap(),
1764            "./c.jsonl"
1765        );
1766    }
1767
1768    #[test]
1769    fn multiple_rows_pick_different_templates() {
1770        let c = cfg(r#"
1771version: 1
1772pipeline:
1773  sources:
1774    users_api:  { type: rest, config: { base_url: https://users.example } }
1775    orders_api: { type: rest, config: { base_url: https://orders.example } }
1776  sinks:
1777    archive: { type: jsonl, config: { path: ./out } }
1778matrix:
1779  - id: load_users
1780    source: { ref: users_api, config: { path: /v1/users } }
1781    sink:   { ref: archive,   config: { path: ./users.jsonl } }
1782  - id: load_orders
1783    source: { ref: orders_api, config: { path: /v1/orders } }
1784    sink:   { ref: archive,    config: { path: ./orders.jsonl } }
1785"#);
1786        let nodes = expand(&c).unwrap();
1787        assert_eq!(nodes.len(), 2);
1788        let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1789        let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1790        assert_eq!(users.source.config["base_url"], "https://users.example");
1791        assert_eq!(users.source.config["path"], "/v1/users");
1792        assert_eq!(orders.source.config["base_url"], "https://orders.example");
1793        assert_eq!(orders.source.config["path"], "/v1/orders");
1794        // Both rows share the same sink template but pick different output paths.
1795        assert_eq!(users.sink.config["path"], "./users.jsonl");
1796        assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1797    }
1798
1799    #[test]
1800    fn sink_template_with_transforms_errors_at_expand() {
1801        let yaml = r#"
1802version: 1
1803pipeline:
1804  source:
1805    type: rest
1806    config: {}
1807  sinks:
1808    bad:
1809      type: jsonl
1810      config: { destination: /tmp/x.jsonl }
1811      transforms:
1812        - { type: flatten, config: { separator: "_" } }
1813matrix:
1814  - id: row
1815    sink: { ref: bad }
1816"#;
1817        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1818            .unwrap();
1819        let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1820        match err {
1821            crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1822            other => panic!("expected TransformsOnSink, got {other:?}"),
1823        }
1824    }
1825
1826    #[test]
1827    fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1828        let yaml = r#"
1829version: 1
1830pipeline:
1831  source:
1832    type: rest
1833    config: {}
1834  sinks:
1835    bad:
1836      type: jsonl
1837      config: { destination: /tmp/x.jsonl }
1838      inherit_transforms: false
1839matrix:
1840  - id: row
1841    sink: { ref: bad }
1842"#;
1843        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1844            .unwrap();
1845        let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1846        match err {
1847            crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1848            other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1849        }
1850    }
1851
1852    fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1853        transforms.iter().map(|t| t.kind.clone()).collect()
1854    }
1855
1856    #[test]
1857    fn three_layer_concat_default_inherit() {
1858        let yaml = r#"
1859version: 1
1860pipeline:
1861  transforms:
1862    - { type: flatten, config: { separator: "_" } }
1863  sources:
1864    s:
1865      type: rest
1866      config: {}
1867      transforms:
1868        - { type: keys_case, config: { mode: snake } }
1869  sink:
1870    type: jsonl
1871    config: { destination: /tmp/x.jsonl }
1872matrix:
1873  - id: row
1874    source: { ref: s }
1875    transforms:
1876      - { type: select, config: { fields: [id] } }
1877"#;
1878        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1879            .unwrap();
1880        let nodes = crate::expand::expand(&cfg).unwrap();
1881        assert_eq!(nodes.len(), 1);
1882        assert_eq!(
1883            kinds(&nodes[0].transforms),
1884            vec!["flatten", "keys_case", "select"]
1885        );
1886    }
1887
1888    #[test]
1889    fn source_inherit_false_drops_pipeline_layer() {
1890        let yaml = r#"
1891version: 1
1892pipeline:
1893  transforms:
1894    - { type: flatten, config: { separator: "_" } }
1895  sources:
1896    s:
1897      type: rest
1898      config: {}
1899      inherit_transforms: false
1900      transforms:
1901        - { type: keys_case, config: { mode: snake } }
1902  sink:
1903    type: jsonl
1904    config: { destination: /tmp/x.jsonl }
1905matrix:
1906  - id: row
1907    source: { ref: s }
1908    transforms:
1909      - { type: select, config: { fields: [id] } }
1910"#;
1911        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1912            .unwrap();
1913        let nodes = crate::expand::expand(&cfg).unwrap();
1914        assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1915    }
1916
1917    #[test]
1918    fn row_inherit_false_drops_pipeline_and_source_layers() {
1919        let yaml = r#"
1920version: 1
1921pipeline:
1922  transforms:
1923    - { type: flatten, config: { separator: "_" } }
1924  sources:
1925    s:
1926      type: rest
1927      config: {}
1928      transforms:
1929        - { type: keys_case, config: { mode: snake } }
1930  sink:
1931    type: jsonl
1932    config: { destination: /tmp/x.jsonl }
1933matrix:
1934  - id: row
1935    source: { ref: s }
1936    inherit_transforms: false
1937    transforms:
1938      - { type: select, config: { fields: [id] } }
1939"#;
1940        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1941            .unwrap();
1942        let nodes = crate::expand::expand(&cfg).unwrap();
1943        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1944    }
1945
1946    #[test]
1947    fn both_inherit_false_yields_row_only() {
1948        let yaml = r#"
1949version: 1
1950pipeline:
1951  transforms:
1952    - { type: flatten, config: { separator: "_" } }
1953  sources:
1954    s:
1955      type: rest
1956      config: {}
1957      inherit_transforms: false
1958      transforms:
1959        - { type: keys_case, config: { mode: snake } }
1960  sink:
1961    type: jsonl
1962    config: { destination: /tmp/x.jsonl }
1963matrix:
1964  - id: row
1965    source: { ref: s }
1966    inherit_transforms: false
1967    transforms:
1968      - { type: select, config: { fields: [id] } }
1969"#;
1970        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1971            .unwrap();
1972        let nodes = crate::expand::expand(&cfg).unwrap();
1973        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1974    }
1975
1976    #[test]
1977    fn all_layers_omitted_yields_empty_transforms() {
1978        let yaml = r#"
1979version: 1
1980pipeline:
1981  source:
1982    type: rest
1983    config: {}
1984  sink:
1985    type: jsonl
1986    config: { destination: /tmp/x.jsonl }
1987matrix:
1988  - id: row
1989"#;
1990        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1991            .unwrap();
1992        let nodes = crate::expand::expand(&cfg).unwrap();
1993        assert!(nodes[0].transforms.is_empty());
1994    }
1995
1996    #[test]
1997    fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
1998        // A root pipeline referencing ${now.date} must pass expand validation.
1999        let yaml = r#"
2000version: 1
2001pipeline:
2002  source: { type: rest, config: {} }
2003  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
2004"#;
2005        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2006        // expand must NOT raise UnknownInterpolationId for `now`.
2007        assert!(expand(&cfg).is_ok());
2008    }
2009
2010    #[test]
2011    fn now_is_a_reserved_row_id() {
2012        let yaml = r#"
2013version: 1
2014pipeline:
2015  source: { type: rest, config: {} }
2016  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2017matrix:
2018  - id: now
2019"#;
2020        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2021        match expand(&cfg).unwrap_err() {
2022            CliError::ReservedRowId { id } => assert_eq!(id, "now"),
2023            other => panic!("expected ReservedRowId, got {other:?}"),
2024        }
2025    }
2026
2027    #[test]
2028    fn expand_rejects_invalid_adaptive_batch_size_at_load() {
2029        // Fail-fast: an invalid execution.adaptive_batch_size block must be
2030        // rejected by `expand` (the gate `faucet validate` uses), not only at
2031        // run time in the executor.
2032        let yaml = r#"
2033version: 1
2034pipeline:
2035  source: { type: rest, config: {} }
2036  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2037execution:
2038  adaptive_batch_size:
2039    enabled: true
2040    min: 5000
2041    max: 100
2042"#;
2043        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2044        let err = expand(&cfg).unwrap_err();
2045        assert!(
2046            err.to_string().contains("adaptive_batch_size.min"),
2047            "expected adaptive validation error, got: {err}"
2048        );
2049    }
2050
2051    #[test]
2052    fn expand_accepts_valid_adaptive_batch_size() {
2053        let yaml = r#"
2054version: 1
2055pipeline:
2056  source: { type: rest, config: {} }
2057  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2058execution:
2059  adaptive_batch_size:
2060    enabled: true
2061    min: 100
2062    max: 5000
2063    target_latency_ms: 500
2064"#;
2065        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2066        assert!(expand(&cfg).is_ok());
2067    }
2068
2069    // --- exactly-once delivery gate tests ---
2070
2071    #[test]
2072    fn exactly_once_rejects_non_cdc_source() {
2073        // rest→stdout with exactly_once must fail: rest is not replay-capable.
2074        let yaml = r#"
2075version: 1
2076delivery: exactly_once
2077pipeline:
2078  source: { type: rest, config: { base_url: https://x } }
2079  sink:   { type: stdout, config: {} }
2080  state:
2081    type: memory
2082    config: {}
2083"#;
2084        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2085        let err = expand(&cfg).unwrap_err();
2086        match &err {
2087            CliError::Config(msg) => {
2088                assert!(
2089                    msg.contains("rest"),
2090                    "expected source kind in error, got: {msg}"
2091                );
2092                assert!(
2093                    msg.contains("exactly_once") || msg.contains("not supported"),
2094                    "got: {msg}"
2095                );
2096            }
2097            other => panic!("expected Config error, got {other:?}"),
2098        }
2099    }
2100
2101    #[test]
2102    fn exactly_once_rejects_non_idempotent_sink() {
2103        // postgres-cdc→stdout: source is OK but stdout is not idempotent.
2104        let yaml = r#"
2105version: 1
2106delivery: exactly_once
2107pipeline:
2108  source: { type: postgres-cdc, config: {} }
2109  sink:   { type: stdout, config: {} }
2110  state:
2111    type: memory
2112    config: {}
2113"#;
2114        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2115        let err = expand(&cfg).unwrap_err();
2116        match &err {
2117            CliError::Config(msg) => {
2118                assert!(
2119                    msg.contains("stdout"),
2120                    "expected sink kind in error, got: {msg}"
2121                );
2122                assert!(
2123                    msg.contains("exactly_once") || msg.contains("not supported"),
2124                    "got: {msg}"
2125                );
2126            }
2127            other => panic!("expected Config error, got {other:?}"),
2128        }
2129    }
2130
2131    #[test]
2132    fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
2133        // postgres-cdc → sqlite + a *durable* state store → must expand
2134        // successfully. (Must not be `memory`: exactly-once needs cross-restart
2135        // durability — see `exactly_once_rejects_memory_state`.)
2136        let yaml = r#"
2137version: 1
2138delivery: exactly_once
2139pipeline:
2140  source: { type: postgres-cdc, config: {} }
2141  sink:   { type: sqlite, config: {} }
2142  state:
2143    type: file
2144    config: { path: "/tmp/faucet-eo-state.json" }
2145"#;
2146        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2147        let nodes = expand(&cfg).unwrap();
2148        assert_eq!(nodes.len(), 1);
2149        assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
2150        assert_eq!(
2151            nodes[0].delivery_guarantee,
2152            faucet_core::DeliveryGuarantee::EffectivelyOnce(
2153                faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2154            )
2155        );
2156    }
2157
2158    #[test]
2159    fn exactly_once_accepted_via_keyed_upsert_with_any_source() {
2160        // rest → postgres with `write_mode: upsert` + `key`: accepted under
2161        // exactly_once via the keyed-upsert mechanism (#292) — no CDC source,
2162        // no state store required.
2163        let yaml = r#"
2164version: 1
2165delivery: exactly_once
2166pipeline:
2167  source: { type: rest, config: { base_url: https://x } }
2168  sink:
2169    type: postgres
2170    config:
2171      connection_url: "postgres://localhost/db"
2172      table_name: t
2173      column_mapping: auto_map
2174      write_mode: upsert
2175      key: [id]
2176"#;
2177        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2178        let nodes = expand(&cfg).unwrap();
2179        assert_eq!(
2180            nodes[0].delivery_guarantee,
2181            faucet_core::DeliveryGuarantee::EffectivelyOnce(
2182                faucet_core::EffectivelyOnceMechanism::KeyedUpsert
2183            )
2184        );
2185    }
2186
2187    #[test]
2188    fn exactly_once_kafka_source_accepted_with_atomic_sink() {
2189        // kafka → sqlite + durable state: the kafka source's offset bookmarks
2190        // qualify it for the atomic-watermark mechanism (#291).
2191        let yaml = r#"
2192version: 1
2193delivery: exactly_once
2194pipeline:
2195  source:
2196    type: kafka
2197    config: { brokers: "localhost:9092", topics: [t], group_id: g, max_messages: 10 }
2198  sink:   { type: sqlite, config: {} }
2199  state:
2200    type: file
2201    config: { path: "/tmp/faucet-eo-kafka-state.json" }
2202"#;
2203        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2204        let nodes = expand(&cfg).unwrap();
2205        assert_eq!(
2206            nodes[0].delivery_guarantee,
2207            faucet_core::DeliveryGuarantee::EffectivelyOnce(
2208                faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2209            )
2210        );
2211    }
2212
2213    #[test]
2214    fn exactly_once_source_error_hints_keyed_upsert_for_capable_sink() {
2215        // rest → postgres (no write_mode): the source error should point at
2216        // the keyed-upsert alternative since postgres is upsert-capable.
2217        let yaml = r#"
2218version: 1
2219delivery: exactly_once
2220pipeline:
2221  source: { type: rest, config: { base_url: https://x } }
2222  sink:
2223    type: postgres
2224    config:
2225      connection_url: "postgres://localhost/db"
2226      table_name: t
2227      column_mapping: auto_map
2228  state:
2229    type: file
2230    config: { path: "/tmp/faucet-eo-hint-state.json" }
2231"#;
2232        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2233        let err = expand(&cfg).unwrap_err();
2234        match &err {
2235            CliError::Config(msg) => assert!(
2236                msg.contains("write_mode: upsert"),
2237                "expected keyed-upsert hint, got: {msg}"
2238            ),
2239            other => panic!("expected Config error, got {other:?}"),
2240        }
2241    }
2242
2243    #[test]
2244    fn derived_guarantee_is_at_least_once_by_default() {
2245        let yaml = r#"
2246version: 1
2247pipeline:
2248  source: { type: rest, config: { base_url: https://x } }
2249  sink:   { type: stdout, config: {} }
2250"#;
2251        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2252        let nodes = expand(&cfg).unwrap();
2253        assert_eq!(
2254            nodes[0].delivery_guarantee,
2255            faucet_core::DeliveryGuarantee::AtLeastOnce
2256        );
2257    }
2258
2259    #[test]
2260    fn exactly_once_rejects_memory_state() {
2261        // A non-durable `memory` store defeats the cross-restart watermark
2262        // guarantee, so it must be rejected at config-load (F24).
2263        let yaml = r#"
2264version: 1
2265delivery: exactly_once
2266pipeline:
2267  source: { type: postgres-cdc, config: {} }
2268  sink:   { type: sqlite, config: {} }
2269  state:
2270    type: memory
2271    config: {}
2272"#;
2273        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2274        let err = expand(&cfg).unwrap_err();
2275        match &err {
2276            CliError::Config(msg) => assert!(
2277                msg.contains("durable") && msg.contains("memory"),
2278                "expected durable/memory mention, got: {msg}"
2279            ),
2280            other => panic!("expected Config error, got {other:?}"),
2281        }
2282    }
2283
2284    #[test]
2285    fn exactly_once_rejects_missing_state_store() {
2286        // Valid CDC pair but no state block → must fail with "requires a state store".
2287        let yaml = r#"
2288version: 1
2289delivery: exactly_once
2290pipeline:
2291  source: { type: postgres-cdc, config: {} }
2292  sink:   { type: sqlite, config: {} }
2293"#;
2294        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2295        let err = expand(&cfg).unwrap_err();
2296        match &err {
2297            CliError::Config(msg) => {
2298                assert!(
2299                    msg.contains("state store") || msg.contains("state"),
2300                    "expected state-store mention in error, got: {msg}"
2301                );
2302            }
2303            other => panic!("expected Config error, got {other:?}"),
2304        }
2305    }
2306
2307    #[test]
2308    fn rejects_upsert_on_unsupported_sink() {
2309        let c = cfg(r#"
2310version: 1
2311name: t
2312pipeline:
2313  source: { type: rest, config: { url: "http://x" } }
2314  sink:   { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
2315"#);
2316        let err = expand(&c).unwrap_err();
2317        let msg = format!("{err}");
2318        assert!(
2319            msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
2320            "{msg}"
2321        );
2322    }
2323
2324    #[test]
2325    fn rejects_upsert_without_key() {
2326        let c = cfg(r#"
2327version: 1
2328name: t
2329pipeline:
2330  source: { type: rest, config: { url: "http://x" } }
2331  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
2332"#);
2333        let err = expand(&c).unwrap_err();
2334        let msg = format!("{err}");
2335        assert!(msg.contains("key"), "{msg}");
2336    }
2337
2338    #[test]
2339    fn accepts_upsert_on_postgres_with_key() {
2340        let c = cfg(r#"
2341version: 1
2342name: t
2343pipeline:
2344  source: { type: rest, config: { url: "http://x" } }
2345  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
2346"#);
2347        assert!(expand(&c).is_ok());
2348    }
2349
2350    #[test]
2351    fn bigquery_upsert_passes_write_mode_gate() {
2352        let c = cfg(r#"
2353version: 1
2354name: t
2355pipeline:
2356  source: { type: rest, config: { url: "http://x" } }
2357  sink:   { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
2358"#);
2359        assert!(expand(&c).is_ok());
2360    }
2361
2362    #[test]
2363    fn accepts_append_by_default_on_any_sink() {
2364        let c = cfg(r#"
2365version: 1
2366name: t
2367pipeline:
2368  source: { type: rest, config: { url: "http://x" } }
2369  sink:   { type: jsonl, config: { path: "out.jsonl" } }
2370"#);
2371        assert!(expand(&c).is_ok());
2372    }
2373
2374    #[test]
2375    fn rejects_delete_without_key() {
2376        let c = cfg(r#"
2377version: 1
2378name: t
2379pipeline:
2380  source: { type: rest, config: { url: "http://x" } }
2381  sink:   { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
2382"#);
2383        let err = expand(&c).unwrap_err();
2384        let msg = format!("{err}");
2385        assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
2386    }
2387
2388    #[test]
2389    fn rejects_unknown_write_mode() {
2390        let c = cfg(r#"
2391version: 1
2392name: t
2393pipeline:
2394  source: { type: rest, config: { url: "http://x" } }
2395  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
2396"#);
2397        let err = expand(&c).unwrap_err();
2398        let msg = format!("{err}");
2399        assert!(
2400            msg.contains("unknown write_mode") && msg.contains("replace"),
2401            "{msg}"
2402        );
2403    }
2404
2405    #[test]
2406    fn rejects_poison_dlq_action_without_dlq() {
2407        let c = cfg(r#"
2408version: 1
2409pipeline:
2410  source: { type: rest, config: { base_url: https://x } }
2411  sink:   { type: jsonl, config: { path: ./o } }
2412resilience:
2413  poison: { max_row_attempts: 3, action: dlq }
2414"#);
2415        let err = expand(&c).unwrap_err();
2416        assert!(
2417            matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
2418            "got: {err:?}"
2419        );
2420    }
2421
2422    #[test]
2423    fn accepts_poison_dlq_action_with_dlq() {
2424        let c = cfg(r#"
2425version: 1
2426pipeline:
2427  source: { type: rest, config: { base_url: https://x } }
2428  sink:   { type: jsonl, config: { path: ./o } }
2429  dlq:
2430    sink: { type: jsonl, config: { path: ./dead.jsonl } }
2431resilience:
2432  poison: { max_row_attempts: 3, action: dlq }
2433"#);
2434        let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
2435        assert_eq!(nodes.len(), 1);
2436    }
2437
2438    #[test]
2439    fn accepts_poison_drop_action_without_dlq() {
2440        // action=drop discards rows in place, so no DLQ is required.
2441        let c = cfg(r#"
2442version: 1
2443pipeline:
2444  source: { type: rest, config: { base_url: https://x } }
2445  sink:   { type: jsonl, config: { path: ./o } }
2446resilience:
2447  poison: { max_row_attempts: 3, action: drop }
2448"#);
2449        let nodes = expand(&c).expect("poison.action=drop needs no dlq");
2450        assert_eq!(nodes.len(), 1);
2451    }
2452
2453    // --- schema-drift composition gate tests ---
2454
2455    #[test]
2456    fn evolve_on_non_evolvable_sink_rejected() {
2457        // jsonl is not evolution-capable; on_drift: evolve must fail.
2458        let c = cfg(r#"
2459version: 1
2460pipeline:
2461  source: { type: rest, config: { base_url: https://x } }
2462  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2463  schema:
2464    on_drift: evolve
2465"#);
2466        let err = expand(&c).unwrap_err();
2467        match &err {
2468            CliError::Config(msg) => {
2469                assert!(
2470                    msg.contains("evolve"),
2471                    "expected evolve mention, got: {msg}"
2472                );
2473                assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
2474            }
2475            other => panic!("expected Config error, got {other:?}"),
2476        }
2477    }
2478
2479    #[test]
2480    fn quarantine_drift_without_dlq_rejected() {
2481        // on_drift: quarantine requires a dlq: block.
2482        let c = cfg(r#"
2483version: 1
2484pipeline:
2485  source: { type: rest, config: { base_url: https://x } }
2486  sink:   { type: postgres, config: {} }
2487  schema:
2488    on_drift: quarantine
2489"#);
2490        let err = expand(&c).unwrap_err();
2491        match &err {
2492            CliError::Config(msg) => {
2493                assert!(
2494                    msg.contains("quarantine"),
2495                    "expected quarantine mention, got: {msg}"
2496                );
2497                assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
2498            }
2499            other => panic!("expected Config error, got {other:?}"),
2500        }
2501    }
2502
2503    #[test]
2504    fn evolve_on_postgres_passes() {
2505        // postgres is evolution-capable; on_drift: evolve must expand.
2506        let c = cfg(r#"
2507version: 1
2508pipeline:
2509  source: { type: rest, config: { base_url: https://x } }
2510  sink:   { type: postgres, config: {} }
2511  schema:
2512    on_drift: evolve
2513"#);
2514        assert!(expand(&c).is_ok());
2515    }
2516}