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