Skip to main content

faucet_cli/
expand.rs

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