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