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