Skip to main content

faucet_cli/
expand.rs

1//! Expand a parsed [`PipelineConfig`] into a flat list of [`ExpandedNode`]s
2//! ready for the executor to run.
3//!
4//! Responsibilities:
5//! - Assign synthetic ids to anonymous rows (`row-0`, `row-1`, …).
6//! - Reject reserved row ids (`env`, `file`, `secret`, `matrix`, `pipeline`).
7//! - Reject duplicate ids.
8//! - Validate that every `parent:` references a known row id and that the
9//!   parent chain has no cycles.
10//! - Deep-merge each row's partial overrides into `pipeline.*`.
11//! - Find every `${id.path}` token surviving from load-time interpolation and
12//!   record where each one came from. Tokens that reference unknown ids
13//!   produce a `CliError::UnknownInterpolationId` here, not at runtime.
14
15use crate::config::{
16    ConnectorSpec, MatrixRow, PartialConnector, PipelineConfig, PipelineSpec, StateStoreSpec,
17    TransformSpec,
18};
19use crate::error::{CliError, CliResult};
20use crate::interpolate::{Directive, iter_directives};
21use crate::merge::merge_value;
22use serde_json::Value;
23use std::collections::{BTreeSet, HashMap, HashSet};
24
25/// Row ids that callers can never use because they collide with
26/// load-time interpolation prefixes or future runtime scopes.
27pub const RESERVED_IDS: &[&str] = &[
28    "env", "file", "secret", "matrix", "pipeline", "now", "backfill",
29];
30
31/// One fully-merged matrix row, ready for the executor.
32#[derive(Debug, Clone)]
33pub struct ExpandedNode {
34    pub id: String,
35    pub row_index: usize,
36    pub role: NodeRole,
37    pub source: ConnectorSpec,
38    pub sink: ConnectorSpec,
39    pub transforms: Vec<TransformSpec>,
40    pub state: Option<StateStoreSpec>,
41    /// Resolved DLQ spec for this row, or `None` if no DLQ applies.
42    pub dlq: Option<crate::config::DlqSpec>,
43    /// Pipeline-level quality spec, shared by every node. `quality:` has no
44    /// matrix-row override in v1, so this is `cfg.pipeline.quality` verbatim.
45    #[cfg(feature = "quality")]
46    pub quality: Option<faucet_core::QualitySpec>,
47    /// Pipeline-level data contract, shared by every node (`contract:` has no
48    /// matrix-row override in v1) — `cfg.pipeline.contract` verbatim.
49    #[cfg(feature = "contract")]
50    pub contract: Option<faucet_core::ContractSpec>,
51    /// Pipeline-level PII masking policy, shared by every node (`masking:` has
52    /// no matrix-row override in v1) — `cfg.pipeline.masking` verbatim. The
53    /// executor compiles it *scoped to this node's sink* ([`sink_ref`] +
54    /// [`sink`].`kind`) so `applies_to` destination-scoping works (#206).
55    ///
56    /// [`sink_ref`]: ExpandedNode::sink_ref
57    /// [`sink`]: ExpandedNode::sink
58    #[cfg(feature = "masking")]
59    pub masking: Option<faucet_core::MaskingSpec>,
60    /// The sink template name this node resolved (`sink.ref`, or `"default"`
61    /// for the legacy singular `pipeline.sink`). Used to scope masking
62    /// `applies_to` rules per destination.
63    pub sink_ref: String,
64    /// Compiled schema-drift policy spec (pipeline-level; same for every node).
65    pub schema: Option<faucet_core::SchemaDriftSpec>,
66    /// Delivery guarantee for this row. Resolved from the row's override or
67    /// falls back to the top-level `cfg.delivery`.
68    pub delivery: faucet_core::DeliveryMode,
69    /// The **derived** end-to-end guarantee this row's source × sink × config
70    /// actually provides (issue #292) — computed for every row regardless of
71    /// the requested `delivery:` mode, so `faucet validate` / `doctor` report
72    /// it truthfully (e.g. a keyed-upsert row is effectively-once even when
73    /// the user did not ask for `exactly_once`).
74    pub delivery_guarantee: faucet_core::DeliveryGuarantee,
75    /// Row ids this node waits for (deduplicated, declaration order). The
76    /// executor starts the node only after every listed row's invocations
77    /// finish successfully; a failed or skipped dependency skips this node.
78    pub depends_on: Vec<String>,
79    /// 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            if let Directive::Deferred { id, .. } = dir
898                && id != "now"
899                && id != "backfill"
900                && !id_set.contains(id)
901            {
902                return Err(CliError::UnknownInterpolationId {
903                    id: id.to_owned(),
904                    token: format!("{token} (in {owner})"),
905                });
906            }
907        }
908        Ok(())
909    })
910}
911
912/// Reject any runtime interpolation token (`${id.path}` parent-record refs and
913/// `${now.*}`) found in `value`. These resolve **only** in source/sink configs;
914/// elsewhere — transform / state / dlq bodies — they would silently reach the
915/// connector as a literal `${...}` string (#146 M2). Load-time directives
916/// (`${env:}`, `${vars.X}`, `${sources.X}`, …) are already resolved before
917/// expansion, so any deferred token still present here is genuinely
918/// unsupported in this location.
919fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
920    walk_strings(value, &mut |s| {
921        for (token, dir) in iter_directives(s) {
922            if let Directive::Deferred { .. } = dir {
923                return Err(CliError::Config(format!(
924                    "interpolation token `{token}` in {location} is not supported: \
925                     `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
926                     resolve only in source/sink configs"
927                )));
928            }
929        }
930        Ok(())
931    })
932}
933
934/// Compute a row's effective tag set = `template_tags` ∪ `row_tags` (#376).
935/// Union — the deliberate exception to `merge.rs`'s array-replace rule.
936/// Validates each tag (charset `^[a-z0-9][a-z0-9_-]*$`, non-empty), dedups, and
937/// returns a sorted, canonical list so `--tag` matching is order-insensitive.
938fn resolve_tags(
939    template_tags: &[String],
940    row_tags: &[String],
941    row_id: &str,
942) -> CliResult<Vec<String>> {
943    let mut set: BTreeSet<String> = BTreeSet::new();
944    for tag in template_tags.iter().chain(row_tags.iter()) {
945        validate_tag(tag, row_id)?;
946        set.insert(tag.clone());
947    }
948    Ok(set.into_iter().collect())
949}
950
951/// A tag must be lowercase kebab/snake: `^[a-z0-9][a-z0-9_-]*$`.
952fn validate_tag(tag: &str, row_id: &str) -> CliResult<()> {
953    let ok = {
954        let mut chars = tag.chars();
955        match chars.next() {
956            Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
957                chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
958            }
959            _ => false,
960        }
961    };
962    if !ok {
963        return Err(CliError::Config(format!(
964            "row '{row_id}': invalid tag '{tag}' — tags must match ^[a-z0-9][a-z0-9_-]*$ \
965             (lowercase letters, digits, `_`, `-`; first char alphanumeric)"
966        )));
967    }
968    Ok(())
969}
970
971fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
972    let _ = walk_strings(value, &mut |s| {
973        for (token, dir) in iter_directives(s) {
974            if let Directive::Deferred { id, path } = dir {
975                // `now` / `backfill` are reserved built-ins resolved at run
976                // time, not parent-record dependencies — skip them so the
977                // executor doesn't treat them as deferred parent-record refs.
978                if id == "now" || id == "backfill" {
979                    continue;
980                }
981                out.push(DeferredRef {
982                    referenced_id: id.to_owned(),
983                    dotted_path: path.to_owned(),
984                    token: token.to_owned(),
985                });
986            }
987        }
988        Ok(())
989    });
990}
991
992fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
993where
994    F: FnMut(&str) -> CliResult<()>,
995{
996    match value {
997        Value::String(s) => f(s),
998        Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
999        Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
1000        _ => Ok(()),
1001    }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007    use crate::config::{OnBatchErrorSpec, parse_with_extension};
1008
1009    fn cfg(yaml: &str) -> PipelineConfig {
1010        parse_with_extension(yaml, "yaml").unwrap()
1011    }
1012
1013    #[test]
1014    fn implicit_single_row_when_matrix_absent() {
1015        let c = cfg(r#"
1016version: 1
1017pipeline:
1018  source: { type: rest, config: { base_url: https://x } }
1019  sink:   { type: jsonl, config: { path: ./o } }
1020"#);
1021        let nodes = expand(&c).unwrap();
1022        assert_eq!(nodes.len(), 1);
1023        assert_eq!(nodes[0].id, "row-0");
1024        assert!(matches!(nodes[0].role, NodeRole::Root));
1025        assert_eq!(nodes[0].source.kind, "rest");
1026        assert_eq!(nodes[0].sink.kind, "jsonl");
1027    }
1028
1029    #[test]
1030    fn rejects_runtime_token_in_dlq_config() {
1031        // M2 (#146): `${now.*}` / `${parent.path}` resolve only in source/sink
1032        // configs. In a dlq config they would silently pass through as a literal
1033        // `${...}` string — expand must reject them with a clear error.
1034        let c = cfg(r#"
1035version: 1
1036pipeline:
1037  source: { type: rest, config: { base_url: https://x } }
1038  sink:   { type: jsonl, config: { path: ./o } }
1039  dlq:
1040    sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
1041"#);
1042        let err = expand(&c).unwrap_err();
1043        assert!(
1044            matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
1045            "got: {err:?}"
1046        );
1047    }
1048
1049    #[test]
1050    fn rejects_runtime_token_in_state_config() {
1051        let c = cfg(r#"
1052version: 1
1053pipeline:
1054  source: { type: rest, config: { base_url: https://x } }
1055  sink:   { type: jsonl, config: { path: ./o } }
1056  state:
1057    type: file
1058    config: { path: "state-${now.date}" }
1059"#);
1060        let err = expand(&c).unwrap_err();
1061        assert!(
1062            matches!(&err, CliError::Config(m) if m.contains("state")),
1063            "got: {err:?}"
1064        );
1065    }
1066
1067    #[test]
1068    fn rejects_runtime_token_in_transform_config() {
1069        let c = cfg(r#"
1070version: 1
1071pipeline:
1072  source: { type: rest, config: { base_url: https://x } }
1073  sink:   { type: jsonl, config: { path: ./o } }
1074  transforms:
1075    - type: set
1076      config: { field: ts, value: "${now.datetime}" }
1077"#);
1078        let err = expand(&c).unwrap_err();
1079        assert!(
1080            matches!(&err, CliError::Config(m) if m.contains("transform")),
1081            "got: {err:?}"
1082        );
1083    }
1084
1085    #[test]
1086    fn allows_runtime_token_in_source_and_sink_configs() {
1087        // The same tokens remain valid in source/sink configs (regression guard
1088        // that M2's rejection didn't over-reach).
1089        let c = cfg(r#"
1090version: 1
1091pipeline:
1092  source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
1093  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1094"#);
1095        let nodes = expand(&c).unwrap();
1096        assert_eq!(nodes.len(), 1);
1097    }
1098
1099    #[test]
1100    fn merges_row_overrides_into_pipeline_source() {
1101        let c = cfg(r#"
1102version: 1
1103pipeline:
1104  source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
1105  sink:   { type: jsonl, config: { path: ./o } }
1106matrix:
1107  - id: users
1108    source: { config: { path: /v1/users, headers: { b: 2 } } }
1109"#);
1110        let nodes = expand(&c).unwrap();
1111        assert_eq!(nodes[0].id, "users");
1112        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1113        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1114        assert_eq!(nodes[0].source.config["headers"]["a"], 1);
1115        assert_eq!(nodes[0].source.config["headers"]["b"], 2);
1116    }
1117
1118    #[test]
1119    fn errors_on_unknown_parent() {
1120        let c = cfg(r#"
1121version: 1
1122pipeline:
1123  source: { type: rest, config: {} }
1124  sink:   { type: jsonl, config: { path: ./o } }
1125matrix:
1126  - id: child
1127    parent: nobody
1128"#);
1129        assert!(matches!(
1130            expand(&c).unwrap_err(),
1131            CliError::UnknownParent { .. }
1132        ));
1133    }
1134
1135    #[test]
1136    fn errors_on_duplicate_ids() {
1137        let c = cfg(r#"
1138version: 1
1139pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1140matrix:
1141  - { id: x }
1142  - { id: x }
1143"#);
1144        assert!(matches!(
1145            expand(&c).unwrap_err(),
1146            CliError::DuplicateRowId { .. }
1147        ));
1148    }
1149
1150    #[test]
1151    fn errors_on_reserved_id() {
1152        let c = cfg(r#"
1153version: 1
1154pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1155matrix:
1156  - { id: env }
1157"#);
1158        assert!(matches!(
1159            expand(&c).unwrap_err(),
1160            CliError::ReservedRowId { .. }
1161        ));
1162    }
1163
1164    #[test]
1165    fn errors_on_self_parent_cycle() {
1166        let c = cfg(r#"
1167version: 1
1168pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1169matrix:
1170  - { id: a, parent: a }
1171"#);
1172        assert!(matches!(
1173            expand(&c).unwrap_err(),
1174            CliError::ParentCycle { .. }
1175        ));
1176    }
1177
1178    #[test]
1179    fn errors_on_two_node_cycle() {
1180        let c = cfg(r#"
1181version: 1
1182pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1183matrix:
1184  - { id: a, parent: b }
1185  - { id: b, parent: a }
1186"#);
1187        assert!(matches!(
1188            expand(&c).unwrap_err(),
1189            CliError::ParentCycle { .. }
1190        ));
1191    }
1192
1193    #[test]
1194    fn errors_on_unknown_dependency() {
1195        let c = cfg(r#"
1196version: 1
1197pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1198matrix:
1199  - { id: facts, depends_on: [nobody] }
1200"#);
1201        match expand(&c).unwrap_err() {
1202            CliError::UnknownDependency { id, depends_on } => {
1203                assert_eq!(id, "facts");
1204                assert_eq!(depends_on, "nobody");
1205            }
1206            other => panic!("expected UnknownDependency, got {other:?}"),
1207        }
1208    }
1209
1210    #[test]
1211    fn errors_on_self_dependency() {
1212        let c = cfg(r#"
1213version: 1
1214pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1215matrix:
1216  - { id: a, depends_on: [a] }
1217"#);
1218        match expand(&c).unwrap_err() {
1219            CliError::DependencyCycle { ids } => assert_eq!(ids, vec!["a".to_string()]),
1220            other => panic!("expected DependencyCycle, got {other:?}"),
1221        }
1222    }
1223
1224    #[test]
1225    fn errors_on_depends_on_cycle() {
1226        let c = cfg(r#"
1227version: 1
1228pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1229matrix:
1230  - { id: a, depends_on: [b] }
1231  - { id: b, depends_on: [a] }
1232"#);
1233        match expand(&c).unwrap_err() {
1234            CliError::DependencyCycle { ids } => {
1235                assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1236            }
1237            other => panic!("expected DependencyCycle, got {other:?}"),
1238        }
1239    }
1240
1241    #[test]
1242    fn errors_on_mixed_parent_depends_on_cycle() {
1243        // `a` is a child of `b` (parent edge b -> a) while `b` waits for `a`
1244        // (dependency edge a -> b). Neither the parent-only walk nor a
1245        // depends_on-only check sees this — only the combined graph does.
1246        let c = cfg(r#"
1247version: 1
1248pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1249matrix:
1250  - { id: a, parent: b }
1251  - { id: b, depends_on: [a] }
1252"#);
1253        match expand(&c).unwrap_err() {
1254            CliError::DependencyCycle { ids } => {
1255                assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
1256            }
1257            other => panic!("expected DependencyCycle, got {other:?}"),
1258        }
1259    }
1260
1261    #[test]
1262    fn depends_on_is_recorded_and_deduped() {
1263        let c = cfg(r#"
1264version: 1
1265pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1266matrix:
1267  - { id: dims }
1268  - { id: staging }
1269  - { id: facts, depends_on: [dims, staging, dims] }
1270"#);
1271        let nodes = expand(&c).unwrap();
1272        let facts = nodes.iter().find(|n| n.id == "facts").unwrap();
1273        assert_eq!(
1274            facts.depends_on,
1275            vec!["dims".to_string(), "staging".to_string()]
1276        );
1277        assert!(matches!(facts.role, NodeRole::Root));
1278        let dims = nodes.iter().find(|n| n.id == "dims").unwrap();
1279        assert!(dims.depends_on.is_empty());
1280    }
1281
1282    #[test]
1283    fn depends_on_may_target_a_child_row() {
1284        // Waiting on a per-record fan-out row is legal: the dependent starts
1285        // only after every one of the child's invocations completes.
1286        let c = cfg(r#"
1287version: 1
1288pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
1289matrix:
1290  - { id: users }
1291  - { id: posts, parent: users }
1292  - { id: rollup, depends_on: [posts] }
1293"#);
1294        let nodes = expand(&c).unwrap();
1295        let rollup = nodes.iter().find(|n| n.id == "rollup").unwrap();
1296        assert_eq!(rollup.depends_on, vec!["posts".to_string()]);
1297    }
1298
1299    #[test]
1300    fn errors_on_unknown_interpolation_id() {
1301        let c = cfg(r#"
1302version: 1
1303pipeline:
1304  source: { type: rest, config: { url: "https://x/${nobody.id}" } }
1305  sink:   { type: jsonl, config: { path: ./o } }
1306"#);
1307        assert!(matches!(
1308            expand(&c).unwrap_err(),
1309            CliError::UnknownInterpolationId { .. }
1310        ));
1311    }
1312
1313    #[test]
1314    fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
1315        // Regression for #78/#39: `${env.foo}` has no colon, so it is a
1316        // deferred reference to id `env`, not a load-time `env:` directive.
1317        // The validator must reject it (as the runtime would), rather than
1318        // silently skipping it and letting `run` fail later.
1319        let c = cfg(r#"
1320version: 1
1321pipeline:
1322  source: { type: rest, config: { url: "https://x/${env.foo}" } }
1323  sink:   { type: jsonl, config: { path: ./o } }
1324"#);
1325        match expand(&c).unwrap_err() {
1326            CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
1327            other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
1328        }
1329    }
1330
1331    #[test]
1332    fn accepts_id_path_when_referenced_row_exists() {
1333        let c = cfg(r#"
1334version: 1
1335pipeline:
1336  source: { type: rest, config: {} }
1337  sink:   { type: jsonl, config: { path: ./o } }
1338matrix:
1339  - id: users
1340  - id: posts
1341    parent: users
1342    source: { config: { path: "/v1/users/${users.id}/posts" } }
1343"#);
1344        let nodes = expand(&c).unwrap();
1345        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1346        assert_eq!(posts.deferred_refs.len(), 1);
1347        assert_eq!(posts.deferred_refs[0].referenced_id, "users");
1348        assert_eq!(posts.deferred_refs[0].dotted_path, "id");
1349    }
1350
1351    #[test]
1352    fn nested_referenced_path_resolves() {
1353        let c = cfg(r#"
1354version: 1
1355pipeline:
1356  source: { type: rest, config: {} }
1357  sink:   { type: jsonl, config: { path: ./o } }
1358matrix:
1359  - id: users
1360  - id: addrs
1361    parent: users
1362    source: { config: { path: "/users/${users.addr.city}/addr" } }
1363"#);
1364        let nodes = expand(&c).unwrap();
1365        let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
1366        assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
1367    }
1368
1369    #[test]
1370    fn roots_come_before_children_in_order() {
1371        let c = cfg(r#"
1372version: 1
1373pipeline:
1374  source: { type: rest, config: {} }
1375  sink:   { type: jsonl, config: { path: ./o } }
1376matrix:
1377  - id: posts
1378    parent: users
1379  - id: users
1380"#);
1381        let nodes = expand(&c).unwrap();
1382        let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
1383        let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
1384        assert!(users_idx < posts_idx, "users must precede posts");
1385    }
1386
1387    #[test]
1388    fn child_node_has_parent_role() {
1389        let c = cfg(r#"
1390version: 1
1391pipeline:
1392  source: { type: rest, config: {} }
1393  sink:   { type: jsonl, config: { path: ./o } }
1394matrix:
1395  - id: users
1396  - id: posts
1397    parent: users
1398    parent_key: user_id
1399"#);
1400        let nodes = expand(&c).unwrap();
1401        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
1402        match &posts.role {
1403            NodeRole::Child {
1404                parent_id,
1405                parent_key,
1406            } => {
1407                assert_eq!(parent_id, "users");
1408                assert_eq!(parent_key, "user_id");
1409            }
1410            other => panic!("expected Child, got {other:?}"),
1411        }
1412    }
1413
1414    #[test]
1415    fn expand_rejects_zero_per_page_budget() {
1416        let yaml = r#"
1417version: 1
1418pipeline:
1419  source: { type: rest, config: {} }
1420  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1421  dlq:
1422    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1423    max_failures_per_page: 0
1424"#;
1425        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1426        let err = expand(&cfg).unwrap_err();
1427        assert!(matches!(
1428            err,
1429            CliError::InvalidDlqBudget {
1430                field: "max_failures_per_page"
1431            }
1432        ));
1433    }
1434
1435    #[test]
1436    fn expand_rejects_zero_total_budget() {
1437        let yaml = r#"
1438version: 1
1439pipeline:
1440  source: { type: rest, config: {} }
1441  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1442  dlq:
1443    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1444    max_failures_total: 0
1445"#;
1446        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1447        let err = expand(&cfg).unwrap_err();
1448        assert!(matches!(
1449            err,
1450            CliError::InvalidDlqBudget {
1451                field: "max_failures_total"
1452            }
1453        ));
1454    }
1455
1456    #[test]
1457    fn expand_rejects_unknown_dlq_sink_kind() {
1458        let yaml = r#"
1459version: 1
1460pipeline:
1461  source: { type: rest, config: {} }
1462  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1463  dlq:
1464    sink: { type: not_a_sink, config: {} }
1465"#;
1466        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1467        let err = expand(&cfg).unwrap_err();
1468        assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
1469    }
1470
1471    #[cfg(feature = "quality")]
1472    #[test]
1473    fn expand_rejects_quarantine_without_dlq() {
1474        // A quality check with `on_failure: quarantine` needs a DLQ to route to.
1475        // `expand` must reject the config so `faucet validate` fails fast.
1476        let yaml = r#"
1477version: 1
1478pipeline:
1479  source: { type: rest, config: {} }
1480  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1481  quality:
1482    record:
1483      - { type: not_null, field: id, on_failure: quarantine }
1484"#;
1485        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1486        let err = expand(&cfg).unwrap_err();
1487        match err {
1488            CliError::Config(msg) => {
1489                assert!(msg.contains("quarantine"), "{msg}");
1490                assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
1491            }
1492            other => panic!("expected Config error, got {other:?}"),
1493        }
1494    }
1495
1496    #[cfg(feature = "quality")]
1497    #[test]
1498    fn expand_accepts_quarantine_with_dlq() {
1499        let yaml = r#"
1500version: 1
1501pipeline:
1502  source: { type: rest, config: {} }
1503  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1504  dlq:
1505    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1506  quality:
1507    record:
1508      - { type: not_null, field: id, on_failure: quarantine }
1509"#;
1510        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1511        let nodes = expand(&cfg).unwrap();
1512        assert_eq!(nodes.len(), 1);
1513        let q = nodes[0]
1514            .quality
1515            .as_ref()
1516            .expect("quality threaded onto node");
1517        assert_eq!(q.record.len(), 1);
1518    }
1519
1520    #[cfg(feature = "quality")]
1521    #[test]
1522    fn expand_accepts_abort_quality_without_dlq() {
1523        // `on_failure: abort` does not route to a DLQ, so no DLQ is required.
1524        let yaml = r#"
1525version: 1
1526pipeline:
1527  source: { type: rest, config: {} }
1528  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1529  quality:
1530    record:
1531      - { type: not_null, field: id, on_failure: abort }
1532"#;
1533        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1534        let nodes = expand(&cfg).unwrap();
1535        assert!(nodes[0].quality.is_some());
1536    }
1537
1538    #[cfg(feature = "contract")]
1539    #[test]
1540    fn expand_rejects_contract_quarantine_without_dlq() {
1541        let yaml = r#"
1542version: 1
1543pipeline:
1544  source: { type: rest, config: {} }
1545  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1546  contract:
1547    version: "1.0.0"
1548    on_breach: quarantine
1549    fields:
1550      - { name: id, type: integer }
1551"#;
1552        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1553        let err = expand(&cfg).unwrap_err();
1554        match err {
1555            CliError::Config(msg) => {
1556                assert!(msg.contains("on_breach: quarantine"), "{msg}");
1557                assert!(msg.contains("dlq"), "{msg}");
1558            }
1559            other => panic!("expected Config error, got {other:?}"),
1560        }
1561    }
1562
1563    #[cfg(feature = "contract")]
1564    #[test]
1565    fn expand_accepts_contract_quarantine_with_dlq() {
1566        let yaml = r#"
1567version: 1
1568pipeline:
1569  source: { type: rest, config: {} }
1570  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1571  dlq:
1572    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1573  contract:
1574    version: "1.0.0"
1575    on_breach: quarantine
1576    fields:
1577      - { name: id, type: integer }
1578"#;
1579        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1580        let nodes = expand(&cfg).unwrap();
1581        assert_eq!(nodes.len(), 1);
1582        let c = nodes[0]
1583            .contract
1584            .as_ref()
1585            .expect("contract threaded onto node");
1586        assert_eq!(c.version, "1.0.0");
1587        assert_eq!(c.fields.len(), 1);
1588    }
1589
1590    #[cfg(feature = "contract")]
1591    #[test]
1592    fn expand_accepts_contract_fail_without_dlq() {
1593        // `on_breach: fail` (the default) does not route to a DLQ.
1594        let yaml = r#"
1595version: 1
1596pipeline:
1597  source: { type: rest, config: {} }
1598  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1599  contract:
1600    version: "1.0.0"
1601    fields:
1602      - { name: id, type: integer }
1603"#;
1604        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1605        let nodes = expand(&cfg).unwrap();
1606        assert!(nodes[0].contract.is_some());
1607    }
1608
1609    #[cfg(feature = "contract")]
1610    #[test]
1611    fn expand_rejects_malformed_contract() {
1612        // A bad regex must surface at expand time (load-time), not mid-run.
1613        let yaml = r#"
1614version: 1
1615pipeline:
1616  source: { type: rest, config: {} }
1617  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1618  contract:
1619    version: "1.0.0"
1620    fields:
1621      - { name: email, type: string, pattern: "[invalid" }
1622"#;
1623        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1624        let err = expand(&cfg).unwrap_err();
1625        match err {
1626            CliError::Config(msg) => assert!(msg.contains("invalid pattern"), "{msg}"),
1627            other => panic!("expected Config error, got {other:?}"),
1628        }
1629    }
1630
1631    #[test]
1632    fn legacy_singular_source_resolves_as_default_template() {
1633        let c = cfg(r#"
1634version: 1
1635pipeline:
1636  source: { type: rest, config: { base_url: https://x } }
1637  sink:   { type: jsonl, config: { path: ./o } }
1638"#);
1639        let nodes = expand(&c).unwrap();
1640        assert_eq!(nodes[0].source.kind, "rest");
1641        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1642    }
1643
1644    #[test]
1645    fn row_with_ref_picks_named_template() {
1646        let c = cfg(r#"
1647version: 1
1648pipeline:
1649  sources:
1650    users_api: { type: rest, config: { base_url: https://x } }
1651  sinks:
1652    archive:   { type: jsonl, config: { path: ./out } }
1653matrix:
1654  - id: load_users
1655    source:
1656      ref: users_api
1657      config: { path: /v1/users }
1658    sink:
1659      ref: archive
1660      config: { path: ./users.jsonl }
1661"#);
1662        let nodes = expand(&c).unwrap();
1663        assert_eq!(nodes[0].source.kind, "rest");
1664        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1665        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1666        assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1667    }
1668
1669    #[test]
1670    fn row_without_ref_falls_back_to_default_template() {
1671        let c = cfg(r#"
1672version: 1
1673pipeline:
1674  source: { type: rest, config: { base_url: https://x } }
1675  sink:   { type: jsonl, config: { path: ./o } }
1676matrix:
1677  - id: users
1678    source: { config: { path: /v1/users } }
1679"#);
1680        let nodes = expand(&c).unwrap();
1681        assert_eq!(nodes[0].source.kind, "rest");
1682        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1683    }
1684
1685    #[test]
1686    fn unknown_template_ref_errors_with_known_list() {
1687        let c = cfg(r#"
1688version: 1
1689pipeline:
1690  sources:
1691    a: { type: rest, config: {} }
1692    b: { type: rest, config: {} }
1693  sinks:
1694    s: { type: jsonl, config: { path: ./o } }
1695matrix:
1696  - id: x
1697    source: { ref: c }
1698    sink: { ref: s }
1699"#);
1700        let err = expand(&c).unwrap_err();
1701        match err {
1702            CliError::UnknownTemplate {
1703                kind,
1704                name,
1705                row_id,
1706                known,
1707            } => {
1708                assert_eq!(kind, "source");
1709                assert_eq!(name, "c");
1710                assert_eq!(row_id, "x");
1711                assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1712            }
1713            other => panic!("expected UnknownTemplate, got {other:?}"),
1714        }
1715    }
1716
1717    #[test]
1718    fn missing_default_template_errors() {
1719        // No singular `source:` and no `sources.default` — a row without a ref
1720        // has nowhere to go.
1721        let c = cfg(r#"
1722version: 1
1723pipeline:
1724  sources:
1725    users_api: { type: rest, config: {} }
1726  sink: { type: jsonl, config: { path: ./o } }
1727matrix:
1728  - id: x
1729    source: { config: { path: /v1 } }
1730"#);
1731        let err = expand(&c).unwrap_err();
1732        match err {
1733            CliError::MissingTemplate { kind, row_id } => {
1734                assert_eq!(kind, "source");
1735                assert_eq!(row_id, "x");
1736            }
1737            other => panic!("expected MissingTemplate, got {other:?}"),
1738        }
1739    }
1740
1741    #[test]
1742    fn duplicate_default_template_errors() {
1743        // Defining both legacy `source:` and `sources.default:` is a conflict.
1744        let c = cfg(r#"
1745version: 1
1746pipeline:
1747  source: { type: rest, config: {} }
1748  sources:
1749    default: { type: rest, config: {} }
1750  sink: { type: jsonl, config: { path: ./o } }
1751"#);
1752        let err = expand(&c).unwrap_err();
1753        match err {
1754            CliError::DuplicateTemplate { kind, name } => {
1755                assert_eq!(kind, "source");
1756                assert_eq!(name, "default");
1757            }
1758            other => panic!("expected DuplicateTemplate, got {other:?}"),
1759        }
1760    }
1761
1762    #[test]
1763    fn row_can_override_template_kind() {
1764        let c = cfg(r#"
1765version: 1
1766pipeline:
1767  sources:
1768    api: { type: rest, config: { base_url: https://x } }
1769  sinks:
1770    out: { type: jsonl, config: { path: ./o } }
1771matrix:
1772  - id: x
1773    source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1774    sink: { ref: out }
1775"#);
1776        let nodes = expand(&c).unwrap();
1777        assert_eq!(nodes[0].source.kind, "graphql");
1778        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1779        assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1780    }
1781
1782    #[test]
1783    fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1784        let yaml = r#"
1785version: 1
1786pipeline:
1787  source: { type: rest, config: {} }
1788  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1789  dlq:
1790    sink: { type: jsonl, config: { path: ./base.jsonl } }
1791matrix:
1792  - id: a
1793  - id: b
1794    dlq: null
1795  - id: c
1796    dlq:
1797      sink: { type: jsonl, config: { path: ./c.jsonl } }
1798      on_batch_error: dlq_all
1799"#;
1800        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1801        let nodes = expand(&cfg).unwrap();
1802        assert_eq!(nodes.len(), 3);
1803        // Row a inherits.
1804        assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1805        assert_eq!(
1806            nodes[0]
1807                .dlq
1808                .as_ref()
1809                .unwrap()
1810                .sink
1811                .config
1812                .get("path")
1813                .unwrap(),
1814            "./base.jsonl"
1815        );
1816        // Row b is disabled.
1817        assert!(nodes[1].dlq.is_none());
1818        // Row c is replaced.
1819        assert_eq!(
1820            nodes[2].dlq.as_ref().unwrap().on_batch_error,
1821            OnBatchErrorSpec::DlqAll
1822        );
1823        assert_eq!(
1824            nodes[2]
1825                .dlq
1826                .as_ref()
1827                .unwrap()
1828                .sink
1829                .config
1830                .get("path")
1831                .unwrap(),
1832            "./c.jsonl"
1833        );
1834    }
1835
1836    #[test]
1837    fn multiple_rows_pick_different_templates() {
1838        let c = cfg(r#"
1839version: 1
1840pipeline:
1841  sources:
1842    users_api:  { type: rest, config: { base_url: https://users.example } }
1843    orders_api: { type: rest, config: { base_url: https://orders.example } }
1844  sinks:
1845    archive: { type: jsonl, config: { path: ./out } }
1846matrix:
1847  - id: load_users
1848    source: { ref: users_api, config: { path: /v1/users } }
1849    sink:   { ref: archive,   config: { path: ./users.jsonl } }
1850  - id: load_orders
1851    source: { ref: orders_api, config: { path: /v1/orders } }
1852    sink:   { ref: archive,    config: { path: ./orders.jsonl } }
1853"#);
1854        let nodes = expand(&c).unwrap();
1855        assert_eq!(nodes.len(), 2);
1856        let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1857        let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1858        assert_eq!(users.source.config["base_url"], "https://users.example");
1859        assert_eq!(users.source.config["path"], "/v1/users");
1860        assert_eq!(orders.source.config["base_url"], "https://orders.example");
1861        assert_eq!(orders.source.config["path"], "/v1/orders");
1862        // Both rows share the same sink template but pick different output paths.
1863        assert_eq!(users.sink.config["path"], "./users.jsonl");
1864        assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1865    }
1866
1867    #[test]
1868    fn sink_template_with_transforms_errors_at_expand() {
1869        let yaml = r#"
1870version: 1
1871pipeline:
1872  source:
1873    type: rest
1874    config: {}
1875  sinks:
1876    bad:
1877      type: jsonl
1878      config: { destination: /tmp/x.jsonl }
1879      transforms:
1880        - { type: flatten, config: { separator: "_" } }
1881matrix:
1882  - id: row
1883    sink: { ref: bad }
1884"#;
1885        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1886            .unwrap();
1887        let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1888        match err {
1889            crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1890            other => panic!("expected TransformsOnSink, got {other:?}"),
1891        }
1892    }
1893
1894    #[test]
1895    fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1896        let yaml = r#"
1897version: 1
1898pipeline:
1899  source:
1900    type: rest
1901    config: {}
1902  sinks:
1903    bad:
1904      type: jsonl
1905      config: { destination: /tmp/x.jsonl }
1906      inherit_transforms: false
1907matrix:
1908  - id: row
1909    sink: { ref: bad }
1910"#;
1911        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1912            .unwrap();
1913        let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1914        match err {
1915            crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1916            other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1917        }
1918    }
1919
1920    fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1921        transforms.iter().map(|t| t.kind.clone()).collect()
1922    }
1923
1924    #[test]
1925    fn three_layer_concat_default_inherit() {
1926        let yaml = r#"
1927version: 1
1928pipeline:
1929  transforms:
1930    - { type: flatten, config: { separator: "_" } }
1931  sources:
1932    s:
1933      type: rest
1934      config: {}
1935      transforms:
1936        - { type: keys_case, config: { mode: snake } }
1937  sink:
1938    type: jsonl
1939    config: { destination: /tmp/x.jsonl }
1940matrix:
1941  - id: row
1942    source: { ref: s }
1943    transforms:
1944      - { type: select, config: { fields: [id] } }
1945"#;
1946        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1947            .unwrap();
1948        let nodes = crate::expand::expand(&cfg).unwrap();
1949        assert_eq!(nodes.len(), 1);
1950        assert_eq!(
1951            kinds(&nodes[0].transforms),
1952            vec!["flatten", "keys_case", "select"]
1953        );
1954    }
1955
1956    #[test]
1957    fn source_inherit_false_drops_pipeline_layer() {
1958        let yaml = r#"
1959version: 1
1960pipeline:
1961  transforms:
1962    - { type: flatten, config: { separator: "_" } }
1963  sources:
1964    s:
1965      type: rest
1966      config: {}
1967      inherit_transforms: false
1968      transforms:
1969        - { type: keys_case, config: { mode: snake } }
1970  sink:
1971    type: jsonl
1972    config: { destination: /tmp/x.jsonl }
1973matrix:
1974  - id: row
1975    source: { ref: s }
1976    transforms:
1977      - { type: select, config: { fields: [id] } }
1978"#;
1979        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1980            .unwrap();
1981        let nodes = crate::expand::expand(&cfg).unwrap();
1982        assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1983    }
1984
1985    #[test]
1986    fn row_inherit_false_drops_pipeline_and_source_layers() {
1987        let yaml = r#"
1988version: 1
1989pipeline:
1990  transforms:
1991    - { type: flatten, config: { separator: "_" } }
1992  sources:
1993    s:
1994      type: rest
1995      config: {}
1996      transforms:
1997        - { type: keys_case, config: { mode: snake } }
1998  sink:
1999    type: jsonl
2000    config: { destination: /tmp/x.jsonl }
2001matrix:
2002  - id: row
2003    source: { ref: s }
2004    inherit_transforms: false
2005    transforms:
2006      - { type: select, config: { fields: [id] } }
2007"#;
2008        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2009            .unwrap();
2010        let nodes = crate::expand::expand(&cfg).unwrap();
2011        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2012    }
2013
2014    #[test]
2015    fn both_inherit_false_yields_row_only() {
2016        let yaml = r#"
2017version: 1
2018pipeline:
2019  transforms:
2020    - { type: flatten, config: { separator: "_" } }
2021  sources:
2022    s:
2023      type: rest
2024      config: {}
2025      inherit_transforms: false
2026      transforms:
2027        - { type: keys_case, config: { mode: snake } }
2028  sink:
2029    type: jsonl
2030    config: { destination: /tmp/x.jsonl }
2031matrix:
2032  - id: row
2033    source: { ref: s }
2034    inherit_transforms: false
2035    transforms:
2036      - { type: select, config: { fields: [id] } }
2037"#;
2038        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2039            .unwrap();
2040        let nodes = crate::expand::expand(&cfg).unwrap();
2041        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
2042    }
2043
2044    #[test]
2045    fn all_layers_omitted_yields_empty_transforms() {
2046        let yaml = r#"
2047version: 1
2048pipeline:
2049  source:
2050    type: rest
2051    config: {}
2052  sink:
2053    type: jsonl
2054    config: { destination: /tmp/x.jsonl }
2055matrix:
2056  - id: row
2057"#;
2058        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
2059            .unwrap();
2060        let nodes = crate::expand::expand(&cfg).unwrap();
2061        assert!(nodes[0].transforms.is_empty());
2062    }
2063
2064    #[test]
2065    fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
2066        // A root pipeline referencing ${now.date} must pass expand validation.
2067        let yaml = r#"
2068version: 1
2069pipeline:
2070  source: { type: rest, config: {} }
2071  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
2072"#;
2073        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2074        // expand must NOT raise UnknownInterpolationId for `now`.
2075        assert!(expand(&cfg).is_ok());
2076    }
2077
2078    #[test]
2079    fn now_is_a_reserved_row_id() {
2080        let yaml = r#"
2081version: 1
2082pipeline:
2083  source: { type: rest, config: {} }
2084  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2085matrix:
2086  - id: now
2087"#;
2088        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2089        match expand(&cfg).unwrap_err() {
2090            CliError::ReservedRowId { id } => assert_eq!(id, "now"),
2091            other => panic!("expected ReservedRowId, got {other:?}"),
2092        }
2093    }
2094
2095    #[test]
2096    fn expand_rejects_invalid_adaptive_batch_size_at_load() {
2097        // Fail-fast: an invalid execution.adaptive_batch_size block must be
2098        // rejected by `expand` (the gate `faucet validate` uses), not only at
2099        // run time in the executor.
2100        let yaml = r#"
2101version: 1
2102pipeline:
2103  source: { type: rest, config: {} }
2104  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2105execution:
2106  adaptive_batch_size:
2107    enabled: true
2108    min: 5000
2109    max: 100
2110"#;
2111        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2112        let err = expand(&cfg).unwrap_err();
2113        assert!(
2114            err.to_string().contains("adaptive_batch_size.min"),
2115            "expected adaptive validation error, got: {err}"
2116        );
2117    }
2118
2119    #[test]
2120    fn expand_accepts_valid_adaptive_batch_size() {
2121        let yaml = r#"
2122version: 1
2123pipeline:
2124  source: { type: rest, config: {} }
2125  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2126execution:
2127  adaptive_batch_size:
2128    enabled: true
2129    min: 100
2130    max: 5000
2131    target_latency_ms: 500
2132"#;
2133        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2134        assert!(expand(&cfg).is_ok());
2135    }
2136
2137    // --- exactly-once delivery gate tests ---
2138
2139    #[test]
2140    fn exactly_once_rejects_non_cdc_source() {
2141        // rest→stdout with exactly_once must fail: rest is not replay-capable.
2142        let yaml = r#"
2143version: 1
2144delivery: exactly_once
2145pipeline:
2146  source: { type: rest, config: { base_url: https://x } }
2147  sink:   { type: stdout, config: {} }
2148  state:
2149    type: memory
2150    config: {}
2151"#;
2152        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2153        let err = expand(&cfg).unwrap_err();
2154        match &err {
2155            CliError::Config(msg) => {
2156                assert!(
2157                    msg.contains("rest"),
2158                    "expected source kind in error, got: {msg}"
2159                );
2160                assert!(
2161                    msg.contains("exactly_once") || msg.contains("not supported"),
2162                    "got: {msg}"
2163                );
2164            }
2165            other => panic!("expected Config error, got {other:?}"),
2166        }
2167    }
2168
2169    #[test]
2170    fn exactly_once_rejects_non_idempotent_sink() {
2171        // postgres-cdc→stdout: source is OK but stdout is not idempotent.
2172        let yaml = r#"
2173version: 1
2174delivery: exactly_once
2175pipeline:
2176  source: { type: postgres-cdc, config: {} }
2177  sink:   { type: stdout, config: {} }
2178  state:
2179    type: memory
2180    config: {}
2181"#;
2182        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2183        let err = expand(&cfg).unwrap_err();
2184        match &err {
2185            CliError::Config(msg) => {
2186                assert!(
2187                    msg.contains("stdout"),
2188                    "expected sink kind in error, got: {msg}"
2189                );
2190                assert!(
2191                    msg.contains("exactly_once") || msg.contains("not supported"),
2192                    "got: {msg}"
2193                );
2194            }
2195            other => panic!("expected Config error, got {other:?}"),
2196        }
2197    }
2198
2199    #[test]
2200    fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
2201        // postgres-cdc → sqlite + a *durable* state store → must expand
2202        // successfully. (Must not be `memory`: exactly-once needs cross-restart
2203        // durability — see `exactly_once_rejects_memory_state`.)
2204        let yaml = r#"
2205version: 1
2206delivery: exactly_once
2207pipeline:
2208  source: { type: postgres-cdc, config: {} }
2209  sink:   { type: sqlite, config: {} }
2210  state:
2211    type: file
2212    config: { path: "/tmp/faucet-eo-state.json" }
2213"#;
2214        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2215        let nodes = expand(&cfg).unwrap();
2216        assert_eq!(nodes.len(), 1);
2217        assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
2218        assert_eq!(
2219            nodes[0].delivery_guarantee,
2220            faucet_core::DeliveryGuarantee::EffectivelyOnce(
2221                faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2222            )
2223        );
2224    }
2225
2226    #[test]
2227    fn exactly_once_accepted_via_keyed_upsert_with_any_source() {
2228        // rest → postgres with `write_mode: upsert` + `key`: accepted under
2229        // exactly_once via the keyed-upsert mechanism (#292) — no CDC source,
2230        // no state store required.
2231        let yaml = r#"
2232version: 1
2233delivery: exactly_once
2234pipeline:
2235  source: { type: rest, config: { base_url: https://x } }
2236  sink:
2237    type: postgres
2238    config:
2239      connection_url: "postgres://localhost/db"
2240      table_name: t
2241      column_mapping: auto_map
2242      write_mode: upsert
2243      key: [id]
2244"#;
2245        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2246        let nodes = expand(&cfg).unwrap();
2247        assert_eq!(
2248            nodes[0].delivery_guarantee,
2249            faucet_core::DeliveryGuarantee::EffectivelyOnce(
2250                faucet_core::EffectivelyOnceMechanism::KeyedUpsert
2251            )
2252        );
2253    }
2254
2255    #[test]
2256    fn exactly_once_kafka_source_accepted_with_atomic_sink() {
2257        // kafka → sqlite + durable state: the kafka source's offset bookmarks
2258        // qualify it for the atomic-watermark mechanism (#291).
2259        let yaml = r#"
2260version: 1
2261delivery: exactly_once
2262pipeline:
2263  source:
2264    type: kafka
2265    config: { brokers: "localhost:9092", topics: [t], group_id: g, max_messages: 10 }
2266  sink:   { type: sqlite, config: {} }
2267  state:
2268    type: file
2269    config: { path: "/tmp/faucet-eo-kafka-state.json" }
2270"#;
2271        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2272        let nodes = expand(&cfg).unwrap();
2273        assert_eq!(
2274            nodes[0].delivery_guarantee,
2275            faucet_core::DeliveryGuarantee::EffectivelyOnce(
2276                faucet_core::EffectivelyOnceMechanism::AtomicWatermark
2277            )
2278        );
2279    }
2280
2281    #[test]
2282    fn exactly_once_source_error_hints_keyed_upsert_for_capable_sink() {
2283        // rest → postgres (no write_mode): the source error should point at
2284        // the keyed-upsert alternative since postgres is upsert-capable.
2285        let yaml = r#"
2286version: 1
2287delivery: exactly_once
2288pipeline:
2289  source: { type: rest, config: { base_url: https://x } }
2290  sink:
2291    type: postgres
2292    config:
2293      connection_url: "postgres://localhost/db"
2294      table_name: t
2295      column_mapping: auto_map
2296  state:
2297    type: file
2298    config: { path: "/tmp/faucet-eo-hint-state.json" }
2299"#;
2300        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2301        let err = expand(&cfg).unwrap_err();
2302        match &err {
2303            CliError::Config(msg) => assert!(
2304                msg.contains("write_mode: upsert"),
2305                "expected keyed-upsert hint, got: {msg}"
2306            ),
2307            other => panic!("expected Config error, got {other:?}"),
2308        }
2309    }
2310
2311    #[test]
2312    fn derived_guarantee_is_at_least_once_by_default() {
2313        let yaml = r#"
2314version: 1
2315pipeline:
2316  source: { type: rest, config: { base_url: https://x } }
2317  sink:   { type: stdout, config: {} }
2318"#;
2319        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2320        let nodes = expand(&cfg).unwrap();
2321        assert_eq!(
2322            nodes[0].delivery_guarantee,
2323            faucet_core::DeliveryGuarantee::AtLeastOnce
2324        );
2325    }
2326
2327    #[test]
2328    fn exactly_once_rejects_memory_state() {
2329        // A non-durable `memory` store defeats the cross-restart watermark
2330        // guarantee, so it must be rejected at config-load (F24).
2331        let yaml = r#"
2332version: 1
2333delivery: exactly_once
2334pipeline:
2335  source: { type: postgres-cdc, config: {} }
2336  sink:   { type: sqlite, config: {} }
2337  state:
2338    type: memory
2339    config: {}
2340"#;
2341        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2342        let err = expand(&cfg).unwrap_err();
2343        match &err {
2344            CliError::Config(msg) => assert!(
2345                msg.contains("durable") && msg.contains("memory"),
2346                "expected durable/memory mention, got: {msg}"
2347            ),
2348            other => panic!("expected Config error, got {other:?}"),
2349        }
2350    }
2351
2352    #[test]
2353    fn exactly_once_rejects_missing_state_store() {
2354        // Valid CDC pair but no state block → must fail with "requires a state store".
2355        let yaml = r#"
2356version: 1
2357delivery: exactly_once
2358pipeline:
2359  source: { type: postgres-cdc, config: {} }
2360  sink:   { type: sqlite, config: {} }
2361"#;
2362        let cfg = parse_with_extension(yaml, "yaml").unwrap();
2363        let err = expand(&cfg).unwrap_err();
2364        match &err {
2365            CliError::Config(msg) => {
2366                assert!(
2367                    msg.contains("state store") || msg.contains("state"),
2368                    "expected state-store mention in error, got: {msg}"
2369                );
2370            }
2371            other => panic!("expected Config error, got {other:?}"),
2372        }
2373    }
2374
2375    #[test]
2376    fn rejects_upsert_on_unsupported_sink() {
2377        let c = cfg(r#"
2378version: 1
2379name: t
2380pipeline:
2381  source: { type: rest, config: { url: "http://x" } }
2382  sink:   { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
2383"#);
2384        let err = expand(&c).unwrap_err();
2385        let msg = format!("{err}");
2386        assert!(
2387            msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
2388            "{msg}"
2389        );
2390    }
2391
2392    #[test]
2393    fn rejects_upsert_without_key() {
2394        let c = cfg(r#"
2395version: 1
2396name: t
2397pipeline:
2398  source: { type: rest, config: { url: "http://x" } }
2399  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
2400"#);
2401        let err = expand(&c).unwrap_err();
2402        let msg = format!("{err}");
2403        assert!(msg.contains("key"), "{msg}");
2404    }
2405
2406    #[test]
2407    fn accepts_upsert_on_postgres_with_key() {
2408        let c = cfg(r#"
2409version: 1
2410name: t
2411pipeline:
2412  source: { type: rest, config: { url: "http://x" } }
2413  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
2414"#);
2415        assert!(expand(&c).is_ok());
2416    }
2417
2418    #[test]
2419    fn bigquery_upsert_passes_write_mode_gate() {
2420        let c = cfg(r#"
2421version: 1
2422name: t
2423pipeline:
2424  source: { type: rest, config: { url: "http://x" } }
2425  sink:   { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
2426"#);
2427        assert!(expand(&c).is_ok());
2428    }
2429
2430    #[test]
2431    fn accepts_append_by_default_on_any_sink() {
2432        let c = cfg(r#"
2433version: 1
2434name: t
2435pipeline:
2436  source: { type: rest, config: { url: "http://x" } }
2437  sink:   { type: jsonl, config: { path: "out.jsonl" } }
2438"#);
2439        assert!(expand(&c).is_ok());
2440    }
2441
2442    #[test]
2443    fn rejects_delete_without_key() {
2444        let c = cfg(r#"
2445version: 1
2446name: t
2447pipeline:
2448  source: { type: rest, config: { url: "http://x" } }
2449  sink:   { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
2450"#);
2451        let err = expand(&c).unwrap_err();
2452        let msg = format!("{err}");
2453        assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
2454    }
2455
2456    #[test]
2457    fn rejects_unknown_write_mode() {
2458        let c = cfg(r#"
2459version: 1
2460name: t
2461pipeline:
2462  source: { type: rest, config: { url: "http://x" } }
2463  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
2464"#);
2465        let err = expand(&c).unwrap_err();
2466        let msg = format!("{err}");
2467        assert!(
2468            msg.contains("unknown write_mode") && msg.contains("replace"),
2469            "{msg}"
2470        );
2471    }
2472
2473    #[test]
2474    fn rejects_poison_dlq_action_without_dlq() {
2475        let c = cfg(r#"
2476version: 1
2477pipeline:
2478  source: { type: rest, config: { base_url: https://x } }
2479  sink:   { type: jsonl, config: { path: ./o } }
2480resilience:
2481  poison: { max_row_attempts: 3, action: dlq }
2482"#);
2483        let err = expand(&c).unwrap_err();
2484        assert!(
2485            matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
2486            "got: {err:?}"
2487        );
2488    }
2489
2490    #[test]
2491    fn accepts_poison_dlq_action_with_dlq() {
2492        let c = cfg(r#"
2493version: 1
2494pipeline:
2495  source: { type: rest, config: { base_url: https://x } }
2496  sink:   { type: jsonl, config: { path: ./o } }
2497  dlq:
2498    sink: { type: jsonl, config: { path: ./dead.jsonl } }
2499resilience:
2500  poison: { max_row_attempts: 3, action: dlq }
2501"#);
2502        let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
2503        assert_eq!(nodes.len(), 1);
2504    }
2505
2506    #[test]
2507    fn accepts_poison_drop_action_without_dlq() {
2508        // action=drop discards rows in place, so no DLQ is required.
2509        let c = cfg(r#"
2510version: 1
2511pipeline:
2512  source: { type: rest, config: { base_url: https://x } }
2513  sink:   { type: jsonl, config: { path: ./o } }
2514resilience:
2515  poison: { max_row_attempts: 3, action: drop }
2516"#);
2517        let nodes = expand(&c).expect("poison.action=drop needs no dlq");
2518        assert_eq!(nodes.len(), 1);
2519    }
2520
2521    // --- schema-drift composition gate tests ---
2522
2523    #[test]
2524    fn evolve_on_non_evolvable_sink_rejected() {
2525        // jsonl is not evolution-capable; on_drift: evolve must fail.
2526        let c = cfg(r#"
2527version: 1
2528pipeline:
2529  source: { type: rest, config: { base_url: https://x } }
2530  sink:   { type: jsonl, config: { path: ./o.jsonl } }
2531  schema:
2532    on_drift: evolve
2533"#);
2534        let err = expand(&c).unwrap_err();
2535        match &err {
2536            CliError::Config(msg) => {
2537                assert!(
2538                    msg.contains("evolve"),
2539                    "expected evolve mention, got: {msg}"
2540                );
2541                assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
2542            }
2543            other => panic!("expected Config error, got {other:?}"),
2544        }
2545    }
2546
2547    #[test]
2548    fn quarantine_drift_without_dlq_rejected() {
2549        // on_drift: quarantine requires a dlq: block.
2550        let c = cfg(r#"
2551version: 1
2552pipeline:
2553  source: { type: rest, config: { base_url: https://x } }
2554  sink:   { type: postgres, config: {} }
2555  schema:
2556    on_drift: quarantine
2557"#);
2558        let err = expand(&c).unwrap_err();
2559        match &err {
2560            CliError::Config(msg) => {
2561                assert!(
2562                    msg.contains("quarantine"),
2563                    "expected quarantine mention, got: {msg}"
2564                );
2565                assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
2566            }
2567            other => panic!("expected Config error, got {other:?}"),
2568        }
2569    }
2570
2571    #[test]
2572    fn evolve_on_postgres_passes() {
2573        // postgres is evolution-capable; on_drift: evolve must expand.
2574        let c = cfg(r#"
2575version: 1
2576pipeline:
2577  source: { type: rest, config: { base_url: https://x } }
2578  sink:   { type: postgres, config: {} }
2579  schema:
2580    on_drift: evolve
2581"#);
2582        assert!(expand(&c).is_ok());
2583    }
2584}