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    /// Delivery guarantee for this row. Resolved from the row's override or
46    /// falls back to the top-level `cfg.delivery`.
47    pub delivery: faucet_core::DeliveryMode,
48    /// Every `${id.path}` placeholder that survived load-time interpolation.
49    /// Populated by `collect_deferred`; the executor uses this to know
50    /// which parent record to feed which row.
51    pub deferred_refs: Vec<DeferredRef>,
52}
53
54#[derive(Debug, Clone)]
55pub enum NodeRole {
56    /// Root node — runs once per pipeline invocation.
57    Root,
58    /// Child node — runs once per record produced by the parent row.
59    Child {
60        parent_id: String,
61        parent_key: String,
62    },
63}
64
65#[derive(Debug, Clone)]
66pub struct DeferredRef {
67    pub referenced_id: String,
68    pub dotted_path: String,
69    pub token: String,
70}
71
72/// In-memory lookup of source / sink templates, built once per `expand()` call.
73/// Combines named entries from `pipeline.sources` / `pipeline.sinks` with the
74/// legacy singular `pipeline.source` / `pipeline.sink` (registered as `default`).
75struct Registry<'a> {
76    sources: HashMap<&'a str, &'a ConnectorSpec>,
77    sinks: HashMap<&'a str, &'a ConnectorSpec>,
78}
79
80impl<'a> Registry<'a> {
81    fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
82        let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
83        if let Some(default) = spec.source.as_ref() {
84            sources.insert("default", default);
85        }
86        for (name, s) in spec.sources.iter() {
87            if sources.contains_key(name.as_str()) {
88                return Err(CliError::DuplicateTemplate {
89                    kind: "source",
90                    name: name.clone(),
91                });
92            }
93            sources.insert(name.as_str(), s);
94        }
95
96        let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
97        if let Some(default) = spec.sink.as_ref() {
98            if default.transforms.is_some() {
99                return Err(CliError::TransformsOnSink {
100                    name: "default".to_string(),
101                });
102            }
103            if !default.inherit_transforms {
104                return Err(CliError::InheritTransformsOnSink {
105                    name: "default".to_string(),
106                });
107            }
108            sinks.insert("default", default);
109        }
110        for (name, s) in spec.sinks.iter() {
111            if sinks.contains_key(name.as_str()) {
112                return Err(CliError::DuplicateTemplate {
113                    kind: "sink",
114                    name: name.clone(),
115                });
116            }
117            if s.transforms.is_some() {
118                return Err(CliError::TransformsOnSink { name: name.clone() });
119            }
120            if !s.inherit_transforms {
121                return Err(CliError::InheritTransformsOnSink { name: name.clone() });
122            }
123            sinks.insert(name.as_str(), s);
124        }
125        Ok(Self { sources, sinks })
126    }
127
128    fn known(&self, kind: &'static str) -> Vec<String> {
129        debug_assert!(
130            matches!(kind, "source" | "sink"),
131            "Registry::known called with kind = {:?}",
132            kind
133        );
134        let map = if kind == "source" {
135            &self.sources
136        } else {
137            &self.sinks
138        };
139        let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
140        out.sort();
141        out
142    }
143
144    fn resolve(
145        &self,
146        kind: &'static str,
147        row_id: &str,
148        overlay: Option<&PartialConnector>,
149    ) -> CliResult<ConnectorSpec> {
150        debug_assert!(
151            matches!(kind, "source" | "sink"),
152            "Registry::resolve called with kind = {:?}",
153            kind
154        );
155        let map = if kind == "source" {
156            &self.sources
157        } else {
158            &self.sinks
159        };
160        let ref_name = overlay
161            .and_then(|p| p.r#ref.as_deref())
162            .unwrap_or("default");
163        let base = map.get(ref_name).ok_or_else(|| {
164            if ref_name == "default" {
165                CliError::MissingTemplate {
166                    kind,
167                    row_id: row_id.to_owned(),
168                }
169            } else {
170                CliError::UnknownTemplate {
171                    kind,
172                    name: ref_name.to_owned(),
173                    row_id: row_id.to_owned(),
174                    known: self.known(kind),
175                }
176            }
177        })?;
178        let mut out = (*base).clone();
179        if let Some(p) = overlay {
180            if let Some(k) = &p.kind {
181                out.kind = k.clone();
182            }
183            if let Some(c) = &p.config {
184                merge_value(&mut out.config, c.clone());
185            }
186        }
187        Ok(out)
188    }
189}
190
191/// Expand `cfg` into a topologically valid list of nodes. Roots come first,
192/// then children in BFS order.
193pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
194    // Fail-fast at config load: validate the execution-level adaptive
195    // batch-size controller here (the shared `validate`/`run`/`preview`/
196    // `doctor`/`schedule` gate) so `faucet validate` rejects a bad block
197    // rather than only surfacing it mid-run in the executor.
198    if let Some(ab) = cfg
199        .execution
200        .as_ref()
201        .and_then(|e| e.adaptive_batch_size.as_ref())
202    {
203        // `validate()` returns `FaucetError::Config` whose message already names
204        // the offending field; propagate it directly (CliError: From<FaucetError>).
205        ab.validate()?;
206    }
207
208    // Implicit single-row case: empty matrix → run pipeline once with no merge.
209    let synthetic_row;
210    let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
211        synthetic_row = [MatrixRow {
212            id: None,
213            parent: None,
214            parent_key: "id".into(),
215            source: None,
216            sink: None,
217            transforms: None,
218            inherit_transforms: true,
219            state: None,
220            dlq: None,
221            delivery: None,
222        }];
223        &synthetic_row
224    } else {
225        &cfg.matrix
226    };
227
228    // 1) Assign / validate ids.
229    let mut ids: Vec<String> = Vec::with_capacity(rows.len());
230    let mut seen: HashSet<String> = HashSet::new();
231    for (i, row) in rows.iter().enumerate() {
232        let id = match &row.id {
233            Some(s) => s.clone(),
234            None => format!("row-{i}"),
235        };
236        if RESERVED_IDS.contains(&id.as_str()) {
237            return Err(CliError::ReservedRowId { id });
238        }
239        if !seen.insert(id.clone()) {
240            return Err(CliError::DuplicateRowId { id });
241        }
242        ids.push(id);
243    }
244    let id_set: HashSet<&str> = ids.iter().map(String::as_str).collect();
245
246    // 2) Validate parents + detect cycles.
247    let mut parents: HashMap<&str, &str> = HashMap::new();
248    for (i, row) in rows.iter().enumerate() {
249        let id = ids[i].as_str();
250        if let Some(parent) = row.parent.as_deref() {
251            if !id_set.contains(parent) {
252                return Err(CliError::UnknownParent {
253                    id: id.to_owned(),
254                    parent: parent.to_owned(),
255                });
256            }
257            if parent == id {
258                return Err(CliError::ParentCycle {
259                    ids: vec![id.to_owned()],
260                });
261            }
262            parents.insert(id, parent);
263        }
264    }
265    detect_cycle(&parents)?;
266
267    // 3) Validate `${id.path}` references — each `id` must be a known row.
268    // We scan the *raw* (pre-merge) row configs because interpolation lives in
269    // strings that survive merging unchanged.
270    for (i, row) in rows.iter().enumerate() {
271        let id = ids[i].as_str();
272        if let Some(p) = &row.source
273            && let Some(c) = &p.config
274        {
275            check_refs(c, &id_set, id)?;
276        }
277        if let Some(p) = &row.sink
278            && let Some(c) = &p.config
279        {
280            check_refs(c, &id_set, id)?;
281        }
282    }
283    if let Some(s) = &cfg.pipeline.source {
284        check_refs(&s.config, &id_set, "pipeline.source")?;
285    }
286    if let Some(s) = &cfg.pipeline.sink {
287        check_refs(&s.config, &id_set, "pipeline.sink")?;
288    }
289    for (name, s) in &cfg.pipeline.sources {
290        check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
291    }
292    for (name, s) in &cfg.pipeline.sinks {
293        check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
294    }
295
296    // 4) Build template registry — validates duplicate default conflicts.
297    let registry = Registry::build(&cfg.pipeline)?;
298
299    // 5) Build expanded nodes. Order: roots first (in declaration order),
300    // then BFS over children — guarantees a parent appears before its children.
301    let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
302    let mut roots: Vec<usize> = Vec::new();
303    for (i, row) in rows.iter().enumerate() {
304        match row.parent.as_deref() {
305            None => roots.push(i),
306            Some(p) => by_parent.entry(p).or_default().push(i),
307        }
308    }
309
310    let mut order: Vec<usize> = Vec::with_capacity(rows.len());
311    let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
312    while let Some(idx) = queue.pop_front() {
313        order.push(idx);
314        if let Some(children) = by_parent.get(ids[idx].as_str()) {
315            queue.extend(children.iter().copied());
316        }
317    }
318    debug_assert_eq!(order.len(), rows.len());
319
320    let mut out = Vec::with_capacity(rows.len());
321    for &i in &order {
322        let row = &rows[i];
323        let row_id = ids[i].as_str();
324        let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
325        let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
326        let role = match &row.parent {
327            None => NodeRole::Root,
328            Some(p) => NodeRole::Child {
329                parent_id: p.clone(),
330                parent_key: row.parent_key.clone(),
331            },
332        };
333        let mut deferred = Vec::new();
334        collect_deferred(&merged_source.config, &mut deferred);
335        collect_deferred(&merged_sink.config, &mut deferred);
336
337        // Resolve transforms, state, and DLQ (row overrides win over base).
338        // Three-layer additive resolution:
339        //   T_pipeline ++ T_source ++ T_row
340        // gated on each layer's `inherit_transforms` flag.
341        let src_inherit = merged_source.inherit_transforms;
342        let row_inherit = row.inherit_transforms;
343        let mut transforms: Vec<TransformSpec> = Vec::new();
344        if src_inherit && row_inherit {
345            transforms.extend(cfg.pipeline.transforms.iter().cloned());
346        }
347        if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
348            transforms.extend(src_ts.iter().cloned());
349        }
350        if let Some(row_ts) = row.transforms.as_ref() {
351            transforms.extend(row_ts.iter().cloned());
352        }
353        let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
354        // Row override wins; fall back to the top-level delivery mode.
355        let delivery = row.delivery.unwrap_or(cfg.delivery);
356        // Three-state match: Some(None) = disable, Some(Some(spec)) = replace,
357        // None = inherit. The naive `.flatten().or_else()` would conflate
358        // disable and absent, silently inheriting on explicit null.
359        let dlq = match row.dlq.clone() {
360            Some(None) => None,
361            Some(Some(spec)) => Some(spec),
362            None => cfg.pipeline.dlq.clone(),
363        };
364
365        if let Some(ref d) = dlq {
366            if matches!(d.max_failures_per_page, Some(0)) {
367                return Err(CliError::InvalidDlqBudget {
368                    field: "max_failures_per_page",
369                });
370            }
371            if matches!(d.max_failures_total, Some(0)) {
372                return Err(CliError::InvalidDlqBudget {
373                    field: "max_failures_total",
374                });
375            }
376            if !crate::registry::sink_exists(&d.sink.kind) {
377                return Err(CliError::UnknownDlqSinkKind {
378                    kind: d.sink.kind.clone(),
379                    context: format!("row `{row_id}`"),
380                });
381            }
382        }
383
384        // Runtime interpolation (`${row.path}` parent refs and `${now.*}`) is
385        // resolved only in source/sink configs. A token in a transform / state
386        // / dlq config would otherwise reach the connector as a literal
387        // `${...}` string with no error (#146 M2) — reject it at expand time.
388        for (ti, t) in transforms.iter().enumerate() {
389            reject_runtime_tokens(
390                &t.config,
391                &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
392            )?;
393        }
394        if let Some(ref st) = state {
395            reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
396        }
397        if let Some(ref d) = dlq {
398            reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
399        }
400
401        // `quality:` is pipeline-level only in v1 (no matrix-row override), so
402        // every node carries the same spec. Compile it once per node to (a)
403        // surface invalid paths/regexes/bounds at expand time, and (b) fail
404        // fast when a quarantine check has no DLQ to route to — the core guards
405        // this at run start too, but catching it here makes `faucet validate`
406        // a friendly, fast failure.
407        #[cfg(feature = "quality")]
408        let quality = cfg.pipeline.quality.clone();
409        #[cfg(feature = "quality")]
410        if let Some(ref spec) = quality {
411            let compiled = faucet_core::CompiledQuality::compile(spec)
412                .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
413            if compiled.requires_dlq() && dlq.is_none() {
414                return Err(CliError::Config(format!(
415                    "row `{row_id}`: a quality check uses `on_failure: quarantine` \
416                     but no DLQ is configured — add a `dlq:` block (or change the \
417                     check's `on_failure` to `abort`)"
418                )));
419            }
420        }
421
422        // Exactly-once delivery gate: enforce source, sink, state, and DLQ
423        // compatibility at config-load time so `faucet validate` catches
424        // unsupported combinations before any run starts.
425        if delivery == faucet_core::DeliveryMode::ExactlyOnce {
426            if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
427                return Err(CliError::Config(format!(
428                    "row '{}': delivery: exactly_once is not supported by source '{}' \
429                     (deterministic-replay sources only: postgres-cdc, mysql-cdc, mongodb-cdc)",
430                    ids[i], merged_source.kind
431                )));
432            }
433            if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
434                return Err(CliError::Config(format!(
435                    "row '{}': delivery: exactly_once is not supported by sink '{}' \
436                     (idempotent sinks only: sqlite, postgres, mysql, mssql, iceberg, bigquery)",
437                    ids[i], merged_sink.kind
438                )));
439            }
440            if state.is_none() {
441                return Err(CliError::Config(format!(
442                    "row '{}': delivery: exactly_once requires a state store",
443                    ids[i]
444                )));
445            }
446            if dlq.is_some() {
447                return Err(CliError::Config(format!(
448                    "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
449                    ids[i]
450                )));
451            }
452        }
453
454        // write_mode × sink validation (load-time): reject an unsupported mode
455        // for the sink kind, and upsert/delete without a key, before any run.
456        // Runs for every row; append rows pass trivially.
457        let requested_mode = merged_sink
458            .config
459            .get("write_mode")
460            .and_then(|v| v.as_str())
461            .unwrap_or("append");
462        let mode = match requested_mode {
463            "append" => faucet_core::WriteMode::Append,
464            "upsert" => faucet_core::WriteMode::Upsert,
465            "delete" => faucet_core::WriteMode::Delete,
466            other => {
467                return Err(CliError::Config(format!(
468                    "row '{}': unknown write_mode '{}' (expected append, upsert, or delete)",
469                    ids[i], other
470                )));
471            }
472        };
473        if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
474            return Err(CliError::Config(format!(
475                "row '{}': write_mode '{}' is not supported by sink '{}' \
476                 (upsert/delete sinks: postgres, sqlite, mysql, mssql, mongodb, elasticsearch)",
477                ids[i], requested_mode, merged_sink.kind
478            )));
479        }
480        if matches!(
481            mode,
482            faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
483        ) {
484            let key_present = merged_sink
485                .config
486                .get("key")
487                .and_then(|v| v.as_array())
488                .map(|a| !a.is_empty())
489                .unwrap_or(false);
490            if !key_present {
491                return Err(CliError::Config(format!(
492                    "row '{}': write_mode '{}' requires a non-empty `key`",
493                    ids[i], requested_mode
494                )));
495            }
496        }
497
498        out.push(ExpandedNode {
499            id: ids[i].clone(),
500            row_index: i,
501            role,
502            source: merged_source,
503            sink: merged_sink,
504            transforms,
505            state,
506            dlq,
507            delivery,
508            #[cfg(feature = "quality")]
509            quality,
510            deferred_refs: deferred,
511        });
512    }
513    Ok(out)
514}
515
516fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
517    // Each node has at most one parent ⇒ cycle detection is "walk parents
518    // until we hit `None` or revisit a node we've already seen this walk".
519    for &start in parents.keys() {
520        let mut visited: BTreeSet<&str> = BTreeSet::new();
521        let mut cur = start;
522        while let Some(&p) = parents.get(cur) {
523            if !visited.insert(cur) {
524                let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
525                return Err(CliError::ParentCycle { ids: chain });
526            }
527            cur = p;
528            if cur == start {
529                let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
530                chain.push(start.to_string());
531                return Err(CliError::ParentCycle { ids: chain });
532            }
533        }
534    }
535    Ok(())
536}
537
538/// Verify that every `${X.path}` token in `value` has `X` listed in `id_set`.
539/// Load-time prefixes (`env`, `file`, `secret`) were already handled and are
540/// ignored here.
541fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
542    walk_strings(value, &mut |s| {
543        for (token, dir) in iter_directives(s) {
544            // Load-time / template directives (`${env:..}`, `${vars.X}`, …) are
545            // resolved before expansion; only deferred `${id.path}` references
546            // are validated here, against the known row ids.
547            // `now` is a reserved built-in deferred id resolved at run time.
548            if let Directive::Deferred { id, .. } = dir
549                && id != "now"
550                && !id_set.contains(id)
551            {
552                return Err(CliError::UnknownInterpolationId {
553                    id: id.to_owned(),
554                    token: format!("{token} (in {owner})"),
555                });
556            }
557        }
558        Ok(())
559    })
560}
561
562/// Reject any runtime interpolation token (`${id.path}` parent-record refs and
563/// `${now.*}`) found in `value`. These resolve **only** in source/sink configs;
564/// elsewhere — transform / state / dlq bodies — they would silently reach the
565/// connector as a literal `${...}` string (#146 M2). Load-time directives
566/// (`${env:}`, `${vars.X}`, `${sources.X}`, …) are already resolved before
567/// expansion, so any deferred token still present here is genuinely
568/// unsupported in this location.
569fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
570    walk_strings(value, &mut |s| {
571        for (token, dir) in iter_directives(s) {
572            if let Directive::Deferred { .. } = dir {
573                return Err(CliError::Config(format!(
574                    "interpolation token `{token}` in {location} is not supported: \
575                     `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
576                     resolve only in source/sink configs"
577                )));
578            }
579        }
580        Ok(())
581    })
582}
583
584fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
585    let _ = walk_strings(value, &mut |s| {
586        for (token, dir) in iter_directives(s) {
587            if let Directive::Deferred { id, path } = dir {
588                // `now` is a reserved built-in resolved at run time, not a
589                // parent-record dependency — skip it so the executor doesn't
590                // treat it as a deferred parent-record reference.
591                if id == "now" {
592                    continue;
593                }
594                out.push(DeferredRef {
595                    referenced_id: id.to_owned(),
596                    dotted_path: path.to_owned(),
597                    token: token.to_owned(),
598                });
599            }
600        }
601        Ok(())
602    });
603}
604
605fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
606where
607    F: FnMut(&str) -> CliResult<()>,
608{
609    match value {
610        Value::String(s) => f(s),
611        Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
612        Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
613        _ => Ok(()),
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::config::{OnBatchErrorSpec, parse_with_extension};
621
622    fn cfg(yaml: &str) -> PipelineConfig {
623        parse_with_extension(yaml, "yaml").unwrap()
624    }
625
626    #[test]
627    fn implicit_single_row_when_matrix_absent() {
628        let c = cfg(r#"
629version: 1
630pipeline:
631  source: { type: rest, config: { base_url: https://x } }
632  sink:   { type: jsonl, config: { path: ./o } }
633"#);
634        let nodes = expand(&c).unwrap();
635        assert_eq!(nodes.len(), 1);
636        assert_eq!(nodes[0].id, "row-0");
637        assert!(matches!(nodes[0].role, NodeRole::Root));
638        assert_eq!(nodes[0].source.kind, "rest");
639        assert_eq!(nodes[0].sink.kind, "jsonl");
640    }
641
642    #[test]
643    fn rejects_runtime_token_in_dlq_config() {
644        // M2 (#146): `${now.*}` / `${parent.path}` resolve only in source/sink
645        // configs. In a dlq config they would silently pass through as a literal
646        // `${...}` string — expand must reject them with a clear error.
647        let c = cfg(r#"
648version: 1
649pipeline:
650  source: { type: rest, config: { base_url: https://x } }
651  sink:   { type: jsonl, config: { path: ./o } }
652  dlq:
653    sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
654"#);
655        let err = expand(&c).unwrap_err();
656        assert!(
657            matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
658            "got: {err:?}"
659        );
660    }
661
662    #[test]
663    fn rejects_runtime_token_in_state_config() {
664        let c = cfg(r#"
665version: 1
666pipeline:
667  source: { type: rest, config: { base_url: https://x } }
668  sink:   { type: jsonl, config: { path: ./o } }
669  state:
670    type: file
671    config: { path: "state-${now.date}" }
672"#);
673        let err = expand(&c).unwrap_err();
674        assert!(
675            matches!(&err, CliError::Config(m) if m.contains("state")),
676            "got: {err:?}"
677        );
678    }
679
680    #[test]
681    fn rejects_runtime_token_in_transform_config() {
682        let c = cfg(r#"
683version: 1
684pipeline:
685  source: { type: rest, config: { base_url: https://x } }
686  sink:   { type: jsonl, config: { path: ./o } }
687  transforms:
688    - type: set
689      config: { field: ts, value: "${now.datetime}" }
690"#);
691        let err = expand(&c).unwrap_err();
692        assert!(
693            matches!(&err, CliError::Config(m) if m.contains("transform")),
694            "got: {err:?}"
695        );
696    }
697
698    #[test]
699    fn allows_runtime_token_in_source_and_sink_configs() {
700        // The same tokens remain valid in source/sink configs (regression guard
701        // that M2's rejection didn't over-reach).
702        let c = cfg(r#"
703version: 1
704pipeline:
705  source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
706  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
707"#);
708        let nodes = expand(&c).unwrap();
709        assert_eq!(nodes.len(), 1);
710    }
711
712    #[test]
713    fn merges_row_overrides_into_pipeline_source() {
714        let c = cfg(r#"
715version: 1
716pipeline:
717  source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
718  sink:   { type: jsonl, config: { path: ./o } }
719matrix:
720  - id: users
721    source: { config: { path: /v1/users, headers: { b: 2 } } }
722"#);
723        let nodes = expand(&c).unwrap();
724        assert_eq!(nodes[0].id, "users");
725        assert_eq!(nodes[0].source.config["base_url"], "https://x");
726        assert_eq!(nodes[0].source.config["path"], "/v1/users");
727        assert_eq!(nodes[0].source.config["headers"]["a"], 1);
728        assert_eq!(nodes[0].source.config["headers"]["b"], 2);
729    }
730
731    #[test]
732    fn errors_on_unknown_parent() {
733        let c = cfg(r#"
734version: 1
735pipeline:
736  source: { type: rest, config: {} }
737  sink:   { type: jsonl, config: { path: ./o } }
738matrix:
739  - id: child
740    parent: nobody
741"#);
742        assert!(matches!(
743            expand(&c).unwrap_err(),
744            CliError::UnknownParent { .. }
745        ));
746    }
747
748    #[test]
749    fn errors_on_duplicate_ids() {
750        let c = cfg(r#"
751version: 1
752pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
753matrix:
754  - { id: x }
755  - { id: x }
756"#);
757        assert!(matches!(
758            expand(&c).unwrap_err(),
759            CliError::DuplicateRowId { .. }
760        ));
761    }
762
763    #[test]
764    fn errors_on_reserved_id() {
765        let c = cfg(r#"
766version: 1
767pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
768matrix:
769  - { id: env }
770"#);
771        assert!(matches!(
772            expand(&c).unwrap_err(),
773            CliError::ReservedRowId { .. }
774        ));
775    }
776
777    #[test]
778    fn errors_on_self_parent_cycle() {
779        let c = cfg(r#"
780version: 1
781pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
782matrix:
783  - { id: a, parent: a }
784"#);
785        assert!(matches!(
786            expand(&c).unwrap_err(),
787            CliError::ParentCycle { .. }
788        ));
789    }
790
791    #[test]
792    fn errors_on_two_node_cycle() {
793        let c = cfg(r#"
794version: 1
795pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
796matrix:
797  - { id: a, parent: b }
798  - { id: b, parent: a }
799"#);
800        assert!(matches!(
801            expand(&c).unwrap_err(),
802            CliError::ParentCycle { .. }
803        ));
804    }
805
806    #[test]
807    fn errors_on_unknown_interpolation_id() {
808        let c = cfg(r#"
809version: 1
810pipeline:
811  source: { type: rest, config: { url: "https://x/${nobody.id}" } }
812  sink:   { type: jsonl, config: { path: ./o } }
813"#);
814        assert!(matches!(
815            expand(&c).unwrap_err(),
816            CliError::UnknownInterpolationId { .. }
817        ));
818    }
819
820    #[test]
821    fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
822        // Regression for #78/#39: `${env.foo}` has no colon, so it is a
823        // deferred reference to id `env`, not a load-time `env:` directive.
824        // The validator must reject it (as the runtime would), rather than
825        // silently skipping it and letting `run` fail later.
826        let c = cfg(r#"
827version: 1
828pipeline:
829  source: { type: rest, config: { url: "https://x/${env.foo}" } }
830  sink:   { type: jsonl, config: { path: ./o } }
831"#);
832        match expand(&c).unwrap_err() {
833            CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
834            other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
835        }
836    }
837
838    #[test]
839    fn accepts_id_path_when_referenced_row_exists() {
840        let c = cfg(r#"
841version: 1
842pipeline:
843  source: { type: rest, config: {} }
844  sink:   { type: jsonl, config: { path: ./o } }
845matrix:
846  - id: users
847  - id: posts
848    parent: users
849    source: { config: { path: "/v1/users/${users.id}/posts" } }
850"#);
851        let nodes = expand(&c).unwrap();
852        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
853        assert_eq!(posts.deferred_refs.len(), 1);
854        assert_eq!(posts.deferred_refs[0].referenced_id, "users");
855        assert_eq!(posts.deferred_refs[0].dotted_path, "id");
856    }
857
858    #[test]
859    fn nested_referenced_path_resolves() {
860        let c = cfg(r#"
861version: 1
862pipeline:
863  source: { type: rest, config: {} }
864  sink:   { type: jsonl, config: { path: ./o } }
865matrix:
866  - id: users
867  - id: addrs
868    parent: users
869    source: { config: { path: "/users/${users.addr.city}/addr" } }
870"#);
871        let nodes = expand(&c).unwrap();
872        let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
873        assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
874    }
875
876    #[test]
877    fn roots_come_before_children_in_order() {
878        let c = cfg(r#"
879version: 1
880pipeline:
881  source: { type: rest, config: {} }
882  sink:   { type: jsonl, config: { path: ./o } }
883matrix:
884  - id: posts
885    parent: users
886  - id: users
887"#);
888        let nodes = expand(&c).unwrap();
889        let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
890        let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
891        assert!(users_idx < posts_idx, "users must precede posts");
892    }
893
894    #[test]
895    fn child_node_has_parent_role() {
896        let c = cfg(r#"
897version: 1
898pipeline:
899  source: { type: rest, config: {} }
900  sink:   { type: jsonl, config: { path: ./o } }
901matrix:
902  - id: users
903  - id: posts
904    parent: users
905    parent_key: user_id
906"#);
907        let nodes = expand(&c).unwrap();
908        let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
909        match &posts.role {
910            NodeRole::Child {
911                parent_id,
912                parent_key,
913            } => {
914                assert_eq!(parent_id, "users");
915                assert_eq!(parent_key, "user_id");
916            }
917            other => panic!("expected Child, got {other:?}"),
918        }
919    }
920
921    #[test]
922    fn expand_rejects_zero_per_page_budget() {
923        let yaml = r#"
924version: 1
925pipeline:
926  source: { type: rest, config: {} }
927  sink:   { type: jsonl, config: { path: ./o.jsonl } }
928  dlq:
929    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
930    max_failures_per_page: 0
931"#;
932        let cfg = parse_with_extension(yaml, "yaml").unwrap();
933        let err = expand(&cfg).unwrap_err();
934        assert!(matches!(
935            err,
936            CliError::InvalidDlqBudget {
937                field: "max_failures_per_page"
938            }
939        ));
940    }
941
942    #[test]
943    fn expand_rejects_zero_total_budget() {
944        let yaml = r#"
945version: 1
946pipeline:
947  source: { type: rest, config: {} }
948  sink:   { type: jsonl, config: { path: ./o.jsonl } }
949  dlq:
950    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
951    max_failures_total: 0
952"#;
953        let cfg = parse_with_extension(yaml, "yaml").unwrap();
954        let err = expand(&cfg).unwrap_err();
955        assert!(matches!(
956            err,
957            CliError::InvalidDlqBudget {
958                field: "max_failures_total"
959            }
960        ));
961    }
962
963    #[test]
964    fn expand_rejects_unknown_dlq_sink_kind() {
965        let yaml = r#"
966version: 1
967pipeline:
968  source: { type: rest, config: {} }
969  sink:   { type: jsonl, config: { path: ./o.jsonl } }
970  dlq:
971    sink: { type: not_a_sink, config: {} }
972"#;
973        let cfg = parse_with_extension(yaml, "yaml").unwrap();
974        let err = expand(&cfg).unwrap_err();
975        assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
976    }
977
978    #[cfg(feature = "quality")]
979    #[test]
980    fn expand_rejects_quarantine_without_dlq() {
981        // A quality check with `on_failure: quarantine` needs a DLQ to route to.
982        // `expand` must reject the config so `faucet validate` fails fast.
983        let yaml = r#"
984version: 1
985pipeline:
986  source: { type: rest, config: {} }
987  sink:   { type: jsonl, config: { path: ./o.jsonl } }
988  quality:
989    record:
990      - { type: not_null, field: id, on_failure: quarantine }
991"#;
992        let cfg = parse_with_extension(yaml, "yaml").unwrap();
993        let err = expand(&cfg).unwrap_err();
994        match err {
995            CliError::Config(msg) => {
996                assert!(msg.contains("quarantine"), "{msg}");
997                assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
998            }
999            other => panic!("expected Config error, got {other:?}"),
1000        }
1001    }
1002
1003    #[cfg(feature = "quality")]
1004    #[test]
1005    fn expand_accepts_quarantine_with_dlq() {
1006        let yaml = r#"
1007version: 1
1008pipeline:
1009  source: { type: rest, config: {} }
1010  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1011  dlq:
1012    sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1013  quality:
1014    record:
1015      - { type: not_null, field: id, on_failure: quarantine }
1016"#;
1017        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1018        let nodes = expand(&cfg).unwrap();
1019        assert_eq!(nodes.len(), 1);
1020        let q = nodes[0]
1021            .quality
1022            .as_ref()
1023            .expect("quality threaded onto node");
1024        assert_eq!(q.record.len(), 1);
1025    }
1026
1027    #[cfg(feature = "quality")]
1028    #[test]
1029    fn expand_accepts_abort_quality_without_dlq() {
1030        // `on_failure: abort` does not route to a DLQ, so no DLQ is required.
1031        let yaml = r#"
1032version: 1
1033pipeline:
1034  source: { type: rest, config: {} }
1035  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1036  quality:
1037    record:
1038      - { type: not_null, field: id, on_failure: abort }
1039"#;
1040        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1041        let nodes = expand(&cfg).unwrap();
1042        assert!(nodes[0].quality.is_some());
1043    }
1044
1045    #[test]
1046    fn legacy_singular_source_resolves_as_default_template() {
1047        let c = cfg(r#"
1048version: 1
1049pipeline:
1050  source: { type: rest, config: { base_url: https://x } }
1051  sink:   { type: jsonl, config: { path: ./o } }
1052"#);
1053        let nodes = expand(&c).unwrap();
1054        assert_eq!(nodes[0].source.kind, "rest");
1055        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1056    }
1057
1058    #[test]
1059    fn row_with_ref_picks_named_template() {
1060        let c = cfg(r#"
1061version: 1
1062pipeline:
1063  sources:
1064    users_api: { type: rest, config: { base_url: https://x } }
1065  sinks:
1066    archive:   { type: jsonl, config: { path: ./out } }
1067matrix:
1068  - id: load_users
1069    source:
1070      ref: users_api
1071      config: { path: /v1/users }
1072    sink:
1073      ref: archive
1074      config: { path: ./users.jsonl }
1075"#);
1076        let nodes = expand(&c).unwrap();
1077        assert_eq!(nodes[0].source.kind, "rest");
1078        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1079        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1080        assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1081    }
1082
1083    #[test]
1084    fn row_without_ref_falls_back_to_default_template() {
1085        let c = cfg(r#"
1086version: 1
1087pipeline:
1088  source: { type: rest, config: { base_url: https://x } }
1089  sink:   { type: jsonl, config: { path: ./o } }
1090matrix:
1091  - id: users
1092    source: { config: { path: /v1/users } }
1093"#);
1094        let nodes = expand(&c).unwrap();
1095        assert_eq!(nodes[0].source.kind, "rest");
1096        assert_eq!(nodes[0].source.config["path"], "/v1/users");
1097    }
1098
1099    #[test]
1100    fn unknown_template_ref_errors_with_known_list() {
1101        let c = cfg(r#"
1102version: 1
1103pipeline:
1104  sources:
1105    a: { type: rest, config: {} }
1106    b: { type: rest, config: {} }
1107  sinks:
1108    s: { type: jsonl, config: { path: ./o } }
1109matrix:
1110  - id: x
1111    source: { ref: c }
1112    sink: { ref: s }
1113"#);
1114        let err = expand(&c).unwrap_err();
1115        match err {
1116            CliError::UnknownTemplate {
1117                kind,
1118                name,
1119                row_id,
1120                known,
1121            } => {
1122                assert_eq!(kind, "source");
1123                assert_eq!(name, "c");
1124                assert_eq!(row_id, "x");
1125                assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1126            }
1127            other => panic!("expected UnknownTemplate, got {other:?}"),
1128        }
1129    }
1130
1131    #[test]
1132    fn missing_default_template_errors() {
1133        // No singular `source:` and no `sources.default` — a row without a ref
1134        // has nowhere to go.
1135        let c = cfg(r#"
1136version: 1
1137pipeline:
1138  sources:
1139    users_api: { type: rest, config: {} }
1140  sink: { type: jsonl, config: { path: ./o } }
1141matrix:
1142  - id: x
1143    source: { config: { path: /v1 } }
1144"#);
1145        let err = expand(&c).unwrap_err();
1146        match err {
1147            CliError::MissingTemplate { kind, row_id } => {
1148                assert_eq!(kind, "source");
1149                assert_eq!(row_id, "x");
1150            }
1151            other => panic!("expected MissingTemplate, got {other:?}"),
1152        }
1153    }
1154
1155    #[test]
1156    fn duplicate_default_template_errors() {
1157        // Defining both legacy `source:` and `sources.default:` is a conflict.
1158        let c = cfg(r#"
1159version: 1
1160pipeline:
1161  source: { type: rest, config: {} }
1162  sources:
1163    default: { type: rest, config: {} }
1164  sink: { type: jsonl, config: { path: ./o } }
1165"#);
1166        let err = expand(&c).unwrap_err();
1167        match err {
1168            CliError::DuplicateTemplate { kind, name } => {
1169                assert_eq!(kind, "source");
1170                assert_eq!(name, "default");
1171            }
1172            other => panic!("expected DuplicateTemplate, got {other:?}"),
1173        }
1174    }
1175
1176    #[test]
1177    fn row_can_override_template_kind() {
1178        let c = cfg(r#"
1179version: 1
1180pipeline:
1181  sources:
1182    api: { type: rest, config: { base_url: https://x } }
1183  sinks:
1184    out: { type: jsonl, config: { path: ./o } }
1185matrix:
1186  - id: x
1187    source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1188    sink: { ref: out }
1189"#);
1190        let nodes = expand(&c).unwrap();
1191        assert_eq!(nodes[0].source.kind, "graphql");
1192        assert_eq!(nodes[0].source.config["base_url"], "https://x");
1193        assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1194    }
1195
1196    #[test]
1197    fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1198        let yaml = r#"
1199version: 1
1200pipeline:
1201  source: { type: rest, config: {} }
1202  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1203  dlq:
1204    sink: { type: jsonl, config: { path: ./base.jsonl } }
1205matrix:
1206  - id: a
1207  - id: b
1208    dlq: null
1209  - id: c
1210    dlq:
1211      sink: { type: jsonl, config: { path: ./c.jsonl } }
1212      on_batch_error: dlq_all
1213"#;
1214        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1215        let nodes = expand(&cfg).unwrap();
1216        assert_eq!(nodes.len(), 3);
1217        // Row a inherits.
1218        assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1219        assert_eq!(
1220            nodes[0]
1221                .dlq
1222                .as_ref()
1223                .unwrap()
1224                .sink
1225                .config
1226                .get("path")
1227                .unwrap(),
1228            "./base.jsonl"
1229        );
1230        // Row b is disabled.
1231        assert!(nodes[1].dlq.is_none());
1232        // Row c is replaced.
1233        assert_eq!(
1234            nodes[2].dlq.as_ref().unwrap().on_batch_error,
1235            OnBatchErrorSpec::DlqAll
1236        );
1237        assert_eq!(
1238            nodes[2]
1239                .dlq
1240                .as_ref()
1241                .unwrap()
1242                .sink
1243                .config
1244                .get("path")
1245                .unwrap(),
1246            "./c.jsonl"
1247        );
1248    }
1249
1250    #[test]
1251    fn multiple_rows_pick_different_templates() {
1252        let c = cfg(r#"
1253version: 1
1254pipeline:
1255  sources:
1256    users_api:  { type: rest, config: { base_url: https://users.example } }
1257    orders_api: { type: rest, config: { base_url: https://orders.example } }
1258  sinks:
1259    archive: { type: jsonl, config: { path: ./out } }
1260matrix:
1261  - id: load_users
1262    source: { ref: users_api, config: { path: /v1/users } }
1263    sink:   { ref: archive,   config: { path: ./users.jsonl } }
1264  - id: load_orders
1265    source: { ref: orders_api, config: { path: /v1/orders } }
1266    sink:   { ref: archive,    config: { path: ./orders.jsonl } }
1267"#);
1268        let nodes = expand(&c).unwrap();
1269        assert_eq!(nodes.len(), 2);
1270        let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1271        let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1272        assert_eq!(users.source.config["base_url"], "https://users.example");
1273        assert_eq!(users.source.config["path"], "/v1/users");
1274        assert_eq!(orders.source.config["base_url"], "https://orders.example");
1275        assert_eq!(orders.source.config["path"], "/v1/orders");
1276        // Both rows share the same sink template but pick different output paths.
1277        assert_eq!(users.sink.config["path"], "./users.jsonl");
1278        assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1279    }
1280
1281    #[test]
1282    fn sink_template_with_transforms_errors_at_expand() {
1283        let yaml = r#"
1284version: 1
1285pipeline:
1286  source:
1287    type: rest
1288    config: {}
1289  sinks:
1290    bad:
1291      type: jsonl
1292      config: { destination: /tmp/x.jsonl }
1293      transforms:
1294        - { type: flatten, config: { separator: "_" } }
1295matrix:
1296  - id: row
1297    sink: { ref: bad }
1298"#;
1299        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1300            .unwrap();
1301        let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1302        match err {
1303            crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1304            other => panic!("expected TransformsOnSink, got {other:?}"),
1305        }
1306    }
1307
1308    #[test]
1309    fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1310        let yaml = r#"
1311version: 1
1312pipeline:
1313  source:
1314    type: rest
1315    config: {}
1316  sinks:
1317    bad:
1318      type: jsonl
1319      config: { destination: /tmp/x.jsonl }
1320      inherit_transforms: false
1321matrix:
1322  - id: row
1323    sink: { ref: bad }
1324"#;
1325        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1326            .unwrap();
1327        let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1328        match err {
1329            crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1330            other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1331        }
1332    }
1333
1334    fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1335        transforms.iter().map(|t| t.kind.clone()).collect()
1336    }
1337
1338    #[test]
1339    fn three_layer_concat_default_inherit() {
1340        let yaml = r#"
1341version: 1
1342pipeline:
1343  transforms:
1344    - { type: flatten, config: { separator: "_" } }
1345  sources:
1346    s:
1347      type: rest
1348      config: {}
1349      transforms:
1350        - { type: keys_case, config: { mode: snake } }
1351  sink:
1352    type: jsonl
1353    config: { destination: /tmp/x.jsonl }
1354matrix:
1355  - id: row
1356    source: { ref: s }
1357    transforms:
1358      - { type: select, config: { fields: [id] } }
1359"#;
1360        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1361            .unwrap();
1362        let nodes = crate::expand::expand(&cfg).unwrap();
1363        assert_eq!(nodes.len(), 1);
1364        assert_eq!(
1365            kinds(&nodes[0].transforms),
1366            vec!["flatten", "keys_case", "select"]
1367        );
1368    }
1369
1370    #[test]
1371    fn source_inherit_false_drops_pipeline_layer() {
1372        let yaml = r#"
1373version: 1
1374pipeline:
1375  transforms:
1376    - { type: flatten, config: { separator: "_" } }
1377  sources:
1378    s:
1379      type: rest
1380      config: {}
1381      inherit_transforms: false
1382      transforms:
1383        - { type: keys_case, config: { mode: snake } }
1384  sink:
1385    type: jsonl
1386    config: { destination: /tmp/x.jsonl }
1387matrix:
1388  - id: row
1389    source: { ref: s }
1390    transforms:
1391      - { type: select, config: { fields: [id] } }
1392"#;
1393        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1394            .unwrap();
1395        let nodes = crate::expand::expand(&cfg).unwrap();
1396        assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1397    }
1398
1399    #[test]
1400    fn row_inherit_false_drops_pipeline_and_source_layers() {
1401        let yaml = r#"
1402version: 1
1403pipeline:
1404  transforms:
1405    - { type: flatten, config: { separator: "_" } }
1406  sources:
1407    s:
1408      type: rest
1409      config: {}
1410      transforms:
1411        - { type: keys_case, config: { mode: snake } }
1412  sink:
1413    type: jsonl
1414    config: { destination: /tmp/x.jsonl }
1415matrix:
1416  - id: row
1417    source: { ref: s }
1418    inherit_transforms: false
1419    transforms:
1420      - { type: select, config: { fields: [id] } }
1421"#;
1422        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1423            .unwrap();
1424        let nodes = crate::expand::expand(&cfg).unwrap();
1425        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1426    }
1427
1428    #[test]
1429    fn both_inherit_false_yields_row_only() {
1430        let yaml = r#"
1431version: 1
1432pipeline:
1433  transforms:
1434    - { type: flatten, config: { separator: "_" } }
1435  sources:
1436    s:
1437      type: rest
1438      config: {}
1439      inherit_transforms: false
1440      transforms:
1441        - { type: keys_case, config: { mode: snake } }
1442  sink:
1443    type: jsonl
1444    config: { destination: /tmp/x.jsonl }
1445matrix:
1446  - id: row
1447    source: { ref: s }
1448    inherit_transforms: false
1449    transforms:
1450      - { type: select, config: { fields: [id] } }
1451"#;
1452        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1453            .unwrap();
1454        let nodes = crate::expand::expand(&cfg).unwrap();
1455        assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1456    }
1457
1458    #[test]
1459    fn all_layers_omitted_yields_empty_transforms() {
1460        let yaml = r#"
1461version: 1
1462pipeline:
1463  source:
1464    type: rest
1465    config: {}
1466  sink:
1467    type: jsonl
1468    config: { destination: /tmp/x.jsonl }
1469matrix:
1470  - id: row
1471"#;
1472        let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1473            .unwrap();
1474        let nodes = crate::expand::expand(&cfg).unwrap();
1475        assert!(nodes[0].transforms.is_empty());
1476    }
1477
1478    #[test]
1479    fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
1480        // A root pipeline referencing ${now.date} must pass expand validation.
1481        let yaml = r#"
1482version: 1
1483pipeline:
1484  source: { type: rest, config: {} }
1485  sink:   { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1486"#;
1487        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1488        // expand must NOT raise UnknownInterpolationId for `now`.
1489        assert!(expand(&cfg).is_ok());
1490    }
1491
1492    #[test]
1493    fn now_is_a_reserved_row_id() {
1494        let yaml = r#"
1495version: 1
1496pipeline:
1497  source: { type: rest, config: {} }
1498  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1499matrix:
1500  - id: now
1501"#;
1502        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1503        match expand(&cfg).unwrap_err() {
1504            CliError::ReservedRowId { id } => assert_eq!(id, "now"),
1505            other => panic!("expected ReservedRowId, got {other:?}"),
1506        }
1507    }
1508
1509    #[test]
1510    fn expand_rejects_invalid_adaptive_batch_size_at_load() {
1511        // Fail-fast: an invalid execution.adaptive_batch_size block must be
1512        // rejected by `expand` (the gate `faucet validate` uses), not only at
1513        // run time in the executor.
1514        let yaml = r#"
1515version: 1
1516pipeline:
1517  source: { type: rest, config: {} }
1518  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1519execution:
1520  adaptive_batch_size:
1521    enabled: true
1522    min: 5000
1523    max: 100
1524"#;
1525        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1526        let err = expand(&cfg).unwrap_err();
1527        assert!(
1528            err.to_string().contains("adaptive_batch_size.min"),
1529            "expected adaptive validation error, got: {err}"
1530        );
1531    }
1532
1533    #[test]
1534    fn expand_accepts_valid_adaptive_batch_size() {
1535        let yaml = r#"
1536version: 1
1537pipeline:
1538  source: { type: rest, config: {} }
1539  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1540execution:
1541  adaptive_batch_size:
1542    enabled: true
1543    min: 100
1544    max: 5000
1545    target_latency_ms: 500
1546"#;
1547        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1548        assert!(expand(&cfg).is_ok());
1549    }
1550
1551    // --- exactly-once delivery gate tests ---
1552
1553    #[test]
1554    fn exactly_once_rejects_non_cdc_source() {
1555        // rest→stdout with exactly_once must fail: rest is not replay-capable.
1556        let yaml = r#"
1557version: 1
1558delivery: exactly_once
1559pipeline:
1560  source: { type: rest, config: { base_url: https://x } }
1561  sink:   { type: stdout, config: {} }
1562  state:
1563    type: memory
1564    config: {}
1565"#;
1566        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1567        let err = expand(&cfg).unwrap_err();
1568        match &err {
1569            CliError::Config(msg) => {
1570                assert!(
1571                    msg.contains("rest"),
1572                    "expected source kind in error, got: {msg}"
1573                );
1574                assert!(
1575                    msg.contains("exactly_once") || msg.contains("not supported"),
1576                    "got: {msg}"
1577                );
1578            }
1579            other => panic!("expected Config error, got {other:?}"),
1580        }
1581    }
1582
1583    #[test]
1584    fn exactly_once_rejects_non_idempotent_sink() {
1585        // postgres-cdc→stdout: source is OK but stdout is not idempotent.
1586        let yaml = r#"
1587version: 1
1588delivery: exactly_once
1589pipeline:
1590  source: { type: postgres-cdc, config: {} }
1591  sink:   { type: stdout, config: {} }
1592  state:
1593    type: memory
1594    config: {}
1595"#;
1596        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1597        let err = expand(&cfg).unwrap_err();
1598        match &err {
1599            CliError::Config(msg) => {
1600                assert!(
1601                    msg.contains("stdout"),
1602                    "expected sink kind in error, got: {msg}"
1603                );
1604                assert!(
1605                    msg.contains("exactly_once") || msg.contains("not supported"),
1606                    "got: {msg}"
1607                );
1608            }
1609            other => panic!("expected Config error, got {other:?}"),
1610        }
1611    }
1612
1613    #[test]
1614    fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
1615        // postgres-cdc → sqlite + state → must expand successfully.
1616        let yaml = r#"
1617version: 1
1618delivery: exactly_once
1619pipeline:
1620  source: { type: postgres-cdc, config: {} }
1621  sink:   { type: sqlite, config: {} }
1622  state:
1623    type: memory
1624    config: {}
1625"#;
1626        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1627        let nodes = expand(&cfg).unwrap();
1628        assert_eq!(nodes.len(), 1);
1629        assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
1630    }
1631
1632    #[test]
1633    fn exactly_once_rejects_missing_state_store() {
1634        // Valid CDC pair but no state block → must fail with "requires a state store".
1635        let yaml = r#"
1636version: 1
1637delivery: exactly_once
1638pipeline:
1639  source: { type: postgres-cdc, config: {} }
1640  sink:   { type: sqlite, config: {} }
1641"#;
1642        let cfg = parse_with_extension(yaml, "yaml").unwrap();
1643        let err = expand(&cfg).unwrap_err();
1644        match &err {
1645            CliError::Config(msg) => {
1646                assert!(
1647                    msg.contains("state store") || msg.contains("state"),
1648                    "expected state-store mention in error, got: {msg}"
1649                );
1650            }
1651            other => panic!("expected Config error, got {other:?}"),
1652        }
1653    }
1654
1655    #[test]
1656    fn rejects_upsert_on_unsupported_sink() {
1657        let c = cfg(r#"
1658version: 1
1659name: t
1660pipeline:
1661  source: { type: rest, config: { url: "http://x" } }
1662  sink:   { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
1663"#);
1664        let err = expand(&c).unwrap_err();
1665        let msg = format!("{err}");
1666        assert!(
1667            msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
1668            "{msg}"
1669        );
1670    }
1671
1672    #[test]
1673    fn rejects_upsert_without_key() {
1674        let c = cfg(r#"
1675version: 1
1676name: t
1677pipeline:
1678  source: { type: rest, config: { url: "http://x" } }
1679  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
1680"#);
1681        let err = expand(&c).unwrap_err();
1682        let msg = format!("{err}");
1683        assert!(msg.contains("key"), "{msg}");
1684    }
1685
1686    #[test]
1687    fn accepts_upsert_on_postgres_with_key() {
1688        let c = cfg(r#"
1689version: 1
1690name: t
1691pipeline:
1692  source: { type: rest, config: { url: "http://x" } }
1693  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1694"#);
1695        assert!(expand(&c).is_ok());
1696    }
1697
1698    #[test]
1699    fn accepts_append_by_default_on_any_sink() {
1700        let c = cfg(r#"
1701version: 1
1702name: t
1703pipeline:
1704  source: { type: rest, config: { url: "http://x" } }
1705  sink:   { type: jsonl, config: { path: "out.jsonl" } }
1706"#);
1707        assert!(expand(&c).is_ok());
1708    }
1709
1710    #[test]
1711    fn rejects_delete_without_key() {
1712        let c = cfg(r#"
1713version: 1
1714name: t
1715pipeline:
1716  source: { type: rest, config: { url: "http://x" } }
1717  sink:   { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
1718"#);
1719        let err = expand(&c).unwrap_err();
1720        let msg = format!("{err}");
1721        assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
1722    }
1723
1724    #[test]
1725    fn rejects_unknown_write_mode() {
1726        let c = cfg(r#"
1727version: 1
1728name: t
1729pipeline:
1730  source: { type: rest, config: { url: "http://x" } }
1731  sink:   { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
1732"#);
1733        let err = expand(&c).unwrap_err();
1734        let msg = format!("{err}");
1735        assert!(
1736            msg.contains("unknown write_mode") && msg.contains("replace"),
1737            "{msg}"
1738        );
1739    }
1740}