Skip to main content

faucet_cli/
expand.rs

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