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