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