Skip to main content

faucet_cli/
expand.rs

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