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 pub schema: Option<faucet_core::SchemaDriftSpec>,
47 pub delivery: faucet_core::DeliveryMode,
50 pub deferred_refs: Vec<DeferredRef>,
54}
55
56#[derive(Debug, Clone)]
57pub enum NodeRole {
58 Root,
60 Child {
62 parent_id: String,
63 parent_key: String,
64 },
65}
66
67#[derive(Debug, Clone)]
68pub struct DeferredRef {
69 pub referenced_id: String,
70 pub dotted_path: String,
71 pub token: String,
72}
73
74struct Registry<'a> {
78 sources: HashMap<&'a str, &'a ConnectorSpec>,
79 sinks: HashMap<&'a str, &'a ConnectorSpec>,
80}
81
82impl<'a> Registry<'a> {
83 fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
84 let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
85 if let Some(default) = spec.source.as_ref() {
86 sources.insert("default", default);
87 }
88 for (name, s) in spec.sources.iter() {
89 if sources.contains_key(name.as_str()) {
90 return Err(CliError::DuplicateTemplate {
91 kind: "source",
92 name: name.clone(),
93 });
94 }
95 sources.insert(name.as_str(), s);
96 }
97
98 let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
99 if let Some(default) = spec.sink.as_ref() {
100 if default.transforms.is_some() {
101 return Err(CliError::TransformsOnSink {
102 name: "default".to_string(),
103 });
104 }
105 if !default.inherit_transforms {
106 return Err(CliError::InheritTransformsOnSink {
107 name: "default".to_string(),
108 });
109 }
110 sinks.insert("default", default);
111 }
112 for (name, s) in spec.sinks.iter() {
113 if sinks.contains_key(name.as_str()) {
114 return Err(CliError::DuplicateTemplate {
115 kind: "sink",
116 name: name.clone(),
117 });
118 }
119 if s.transforms.is_some() {
120 return Err(CliError::TransformsOnSink { name: name.clone() });
121 }
122 if !s.inherit_transforms {
123 return Err(CliError::InheritTransformsOnSink { name: name.clone() });
124 }
125 sinks.insert(name.as_str(), s);
126 }
127 Ok(Self { sources, sinks })
128 }
129
130 fn known(&self, kind: &'static str) -> Vec<String> {
131 debug_assert!(
132 matches!(kind, "source" | "sink"),
133 "Registry::known called with kind = {:?}",
134 kind
135 );
136 let map = if kind == "source" {
137 &self.sources
138 } else {
139 &self.sinks
140 };
141 let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
142 out.sort();
143 out
144 }
145
146 fn resolve(
147 &self,
148 kind: &'static str,
149 row_id: &str,
150 overlay: Option<&PartialConnector>,
151 ) -> CliResult<ConnectorSpec> {
152 debug_assert!(
153 matches!(kind, "source" | "sink"),
154 "Registry::resolve called with kind = {:?}",
155 kind
156 );
157 let map = if kind == "source" {
158 &self.sources
159 } else {
160 &self.sinks
161 };
162 let ref_name = overlay
163 .and_then(|p| p.r#ref.as_deref())
164 .unwrap_or("default");
165 let base = map.get(ref_name).ok_or_else(|| {
166 if ref_name == "default" {
167 CliError::MissingTemplate {
168 kind,
169 row_id: row_id.to_owned(),
170 }
171 } else {
172 CliError::UnknownTemplate {
173 kind,
174 name: ref_name.to_owned(),
175 row_id: row_id.to_owned(),
176 known: self.known(kind),
177 }
178 }
179 })?;
180 let mut out = (*base).clone();
181 if let Some(p) = overlay {
182 if let Some(k) = &p.kind {
183 out.kind = k.clone();
184 }
185 if let Some(c) = &p.config {
186 merge_value(&mut out.config, c.clone());
187 }
188 }
189 Ok(out)
190 }
191}
192
193pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
196 if let Some(ab) = cfg
201 .execution
202 .as_ref()
203 .and_then(|e| e.adaptive_batch_size.as_ref())
204 {
205 ab.validate()?;
208 }
209
210 let synthetic_row;
212 let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
213 synthetic_row = [MatrixRow {
214 id: None,
215 parent: None,
216 parent_key: "id".into(),
217 source: None,
218 sink: None,
219 transforms: None,
220 inherit_transforms: true,
221 state: None,
222 dlq: None,
223 delivery: None,
224 }];
225 &synthetic_row
226 } else {
227 &cfg.matrix
228 };
229
230 let mut ids: Vec<String> = Vec::with_capacity(rows.len());
232 let mut seen: HashSet<String> = HashSet::new();
233 for (i, row) in rows.iter().enumerate() {
234 let id = match &row.id {
235 Some(s) => s.clone(),
236 None => format!("row-{i}"),
237 };
238 if RESERVED_IDS.contains(&id.as_str()) {
239 return Err(CliError::ReservedRowId { id });
240 }
241 if !seen.insert(id.clone()) {
242 return Err(CliError::DuplicateRowId { id });
243 }
244 ids.push(id);
245 }
246 let id_set: HashSet<&str> = ids.iter().map(String::as_str).collect();
247
248 let mut parents: HashMap<&str, &str> = HashMap::new();
250 for (i, row) in rows.iter().enumerate() {
251 let id = ids[i].as_str();
252 if let Some(parent) = row.parent.as_deref() {
253 if !id_set.contains(parent) {
254 return Err(CliError::UnknownParent {
255 id: id.to_owned(),
256 parent: parent.to_owned(),
257 });
258 }
259 if parent == id {
260 return Err(CliError::ParentCycle {
261 ids: vec![id.to_owned()],
262 });
263 }
264 parents.insert(id, parent);
265 }
266 }
267 detect_cycle(&parents)?;
268
269 for (i, row) in rows.iter().enumerate() {
273 let id = ids[i].as_str();
274 if let Some(p) = &row.source
275 && let Some(c) = &p.config
276 {
277 check_refs(c, &id_set, id)?;
278 }
279 if let Some(p) = &row.sink
280 && let Some(c) = &p.config
281 {
282 check_refs(c, &id_set, id)?;
283 }
284 }
285 if let Some(s) = &cfg.pipeline.source {
286 check_refs(&s.config, &id_set, "pipeline.source")?;
287 }
288 if let Some(s) = &cfg.pipeline.sink {
289 check_refs(&s.config, &id_set, "pipeline.sink")?;
290 }
291 for (name, s) in &cfg.pipeline.sources {
292 check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
293 }
294 for (name, s) in &cfg.pipeline.sinks {
295 check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
296 }
297
298 let registry = Registry::build(&cfg.pipeline)?;
300
301 let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
304 let mut roots: Vec<usize> = Vec::new();
305 for (i, row) in rows.iter().enumerate() {
306 match row.parent.as_deref() {
307 None => roots.push(i),
308 Some(p) => by_parent.entry(p).or_default().push(i),
309 }
310 }
311
312 let mut order: Vec<usize> = Vec::with_capacity(rows.len());
313 let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
314 while let Some(idx) = queue.pop_front() {
315 order.push(idx);
316 if let Some(children) = by_parent.get(ids[idx].as_str()) {
317 queue.extend(children.iter().copied());
318 }
319 }
320 debug_assert_eq!(order.len(), rows.len());
321
322 let mut out = Vec::with_capacity(rows.len());
323 for &i in &order {
324 let row = &rows[i];
325 let row_id = ids[i].as_str();
326 let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
327 let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
328 let role = match &row.parent {
329 None => NodeRole::Root,
330 Some(p) => NodeRole::Child {
331 parent_id: p.clone(),
332 parent_key: row.parent_key.clone(),
333 },
334 };
335 let mut deferred = Vec::new();
336 collect_deferred(&merged_source.config, &mut deferred);
337 collect_deferred(&merged_sink.config, &mut deferred);
338
339 let src_inherit = merged_source.inherit_transforms;
344 let row_inherit = row.inherit_transforms;
345 let mut transforms: Vec<TransformSpec> = Vec::new();
346 if src_inherit && row_inherit {
347 transforms.extend(cfg.pipeline.transforms.iter().cloned());
348 }
349 if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
350 transforms.extend(src_ts.iter().cloned());
351 }
352 if let Some(row_ts) = row.transforms.as_ref() {
353 transforms.extend(row_ts.iter().cloned());
354 }
355 let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
356 let delivery = row.delivery.unwrap_or(cfg.delivery);
358 let dlq = match row.dlq.clone() {
362 Some(None) => None,
363 Some(Some(spec)) => Some(spec),
364 None => cfg.pipeline.dlq.clone(),
365 };
366
367 if let Some(ref d) = dlq {
368 if matches!(d.max_failures_per_page, Some(0)) {
369 return Err(CliError::InvalidDlqBudget {
370 field: "max_failures_per_page",
371 });
372 }
373 if matches!(d.max_failures_total, Some(0)) {
374 return Err(CliError::InvalidDlqBudget {
375 field: "max_failures_total",
376 });
377 }
378 if !crate::registry::sink_exists(&d.sink.kind) {
379 return Err(CliError::UnknownDlqSinkKind {
380 kind: d.sink.kind.clone(),
381 context: format!("row `{row_id}`"),
382 });
383 }
384 }
385
386 for (ti, t) in transforms.iter().enumerate() {
391 reject_runtime_tokens(
392 &t.config,
393 &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
394 )?;
395 }
396 if let Some(ref st) = state {
397 reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
398 }
399 if let Some(ref d) = dlq {
400 reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
401 }
402
403 #[cfg(feature = "quality")]
410 let quality = cfg.pipeline.quality.clone();
411 #[cfg(feature = "quality")]
412 if let Some(ref spec) = quality {
413 let compiled = faucet_core::CompiledQuality::compile(spec)
414 .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
415 if compiled.requires_dlq() && dlq.is_none() {
416 return Err(CliError::Config(format!(
417 "row `{row_id}`: a quality check uses `on_failure: quarantine` \
418 but no DLQ is configured — add a `dlq:` block (or change the \
419 check's `on_failure` to `abort`)"
420 )));
421 }
422 }
423
424 if delivery == faucet_core::DeliveryMode::ExactlyOnce {
428 if !crate::registry::source_supports_exactly_once(&merged_source.kind) {
429 return Err(CliError::Config(format!(
430 "row '{}': delivery: exactly_once is not supported by source '{}' \
431 (deterministic-replay sources only: {})",
432 ids[i],
433 merged_source.kind,
434 crate::registry::EXACTLY_ONCE_SOURCE_KINDS.join(", ")
435 )));
436 }
437 if !crate::registry::sink_supports_idempotent_writes(&merged_sink.kind) {
438 return Err(CliError::Config(format!(
439 "row '{}': delivery: exactly_once is not supported by sink '{}' \
440 (idempotent sinks only: {})",
441 ids[i],
442 merged_sink.kind,
443 crate::registry::IDEMPOTENT_SINK_KINDS.join(", ")
444 )));
445 }
446 match state.as_ref() {
454 None => {
455 return Err(CliError::Config(format!(
456 "row '{}': delivery: exactly_once requires a state store",
457 ids[i]
458 )));
459 }
460 Some(s) if s.kind == "memory" => {
461 return Err(CliError::Config(format!(
462 "row '{}': delivery: exactly_once requires a durable state store, \
463 not `memory` — the cross-restart watermark/sequence guarantee \
464 depends on it (use `file`, `redis`, or `postgres`)",
465 ids[i]
466 )));
467 }
468 Some(_) => {}
469 }
470 if dlq.is_some() {
471 return Err(CliError::Config(format!(
472 "row '{}': delivery: exactly_once is not compatible with a DLQ in this version",
473 ids[i]
474 )));
475 }
476 }
477
478 if let Some(spec) = &cfg.resilience
482 && matches!(
483 spec.poison.as_ref().map(|p| p.action),
484 Some(crate::config::PoisonActionSpec::Dlq)
485 )
486 && dlq.is_none()
487 {
488 return Err(CliError::Config(format!(
489 "row '{row_id}': resilience.poison.action=dlq requires a dlq: block"
490 )));
491 }
492
493 let requested_mode = merged_sink
497 .config
498 .get("write_mode")
499 .and_then(|v| v.as_str())
500 .unwrap_or("append");
501 let mode = match requested_mode {
502 "append" => faucet_core::WriteMode::Append,
503 "upsert" => faucet_core::WriteMode::Upsert,
504 "delete" => faucet_core::WriteMode::Delete,
505 other => {
506 return Err(CliError::Config(format!(
507 "row '{}': unknown write_mode '{}' (expected append, upsert, or delete)",
508 ids[i], other
509 )));
510 }
511 };
512 if !crate::registry::sink_supported_write_modes(&merged_sink.kind).contains(&mode) {
513 return Err(CliError::Config(format!(
514 "row '{}': write_mode '{}' is not supported by sink '{}' \
515 (upsert/delete sinks: {})",
516 ids[i],
517 requested_mode,
518 merged_sink.kind,
519 crate::registry::UPSERT_SINK_KINDS.join(", ")
520 )));
521 }
522 if matches!(
523 mode,
524 faucet_core::WriteMode::Upsert | faucet_core::WriteMode::Delete
525 ) {
526 let key_present = merged_sink
527 .config
528 .get("key")
529 .and_then(|v| v.as_array())
530 .map(|a| !a.is_empty())
531 .unwrap_or(false);
532 if !key_present {
533 return Err(CliError::Config(format!(
534 "row '{}': write_mode '{}' requires a non-empty `key`",
535 ids[i], requested_mode
536 )));
537 }
538 }
539
540 if let Some(ref sd) = cfg.pipeline.schema {
545 let policy = faucet_core::SchemaDriftPolicy::compile(sd);
546 if policy.on_drift == faucet_core::OnDrift::Evolve
547 && !crate::registry::sink_supports_schema_evolution(&merged_sink.kind)
548 {
549 return Err(CliError::Config(format!(
550 "row '{}': schema.on_drift: evolve is not supported by sink '{}' \
551 (evolvable sinks: postgres, mysql, mssql, sqlite, bigquery, elasticsearch)",
552 ids[i], merged_sink.kind
553 )));
554 }
555 if policy.requires_dlq() && dlq.is_none() {
556 return Err(CliError::Config(format!(
557 "row '{}': schema.on_drift/on_incompatible 'quarantine' requires a `dlq:` block",
558 ids[i]
559 )));
560 }
561 if policy.requires_dlq() && delivery == faucet_core::DeliveryMode::ExactlyOnce {
562 return Err(CliError::Config(format!(
563 "row '{}': schema quarantine is incompatible with delivery: exactly_once \
564 (exactly_once forbids a DLQ)",
565 ids[i]
566 )));
567 }
568 }
569
570 out.push(ExpandedNode {
571 id: ids[i].clone(),
572 row_index: i,
573 role,
574 source: merged_source,
575 sink: merged_sink,
576 transforms,
577 state,
578 dlq,
579 delivery,
580 #[cfg(feature = "quality")]
581 quality,
582 schema: cfg.pipeline.schema.clone(),
583 deferred_refs: deferred,
584 });
585 }
586 Ok(out)
587}
588
589fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
590 for &start in parents.keys() {
593 let mut visited: BTreeSet<&str> = BTreeSet::new();
594 let mut cur = start;
595 while let Some(&p) = parents.get(cur) {
596 if !visited.insert(cur) {
597 let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
598 return Err(CliError::ParentCycle { ids: chain });
599 }
600 cur = p;
601 if cur == start {
602 let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
603 chain.push(start.to_string());
604 return Err(CliError::ParentCycle { ids: chain });
605 }
606 }
607 }
608 Ok(())
609}
610
611fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
615 walk_strings(value, &mut |s| {
616 for (token, dir) in iter_directives(s) {
617 if let Directive::Deferred { id, .. } = dir
622 && id != "now"
623 && !id_set.contains(id)
624 {
625 return Err(CliError::UnknownInterpolationId {
626 id: id.to_owned(),
627 token: format!("{token} (in {owner})"),
628 });
629 }
630 }
631 Ok(())
632 })
633}
634
635fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
643 walk_strings(value, &mut |s| {
644 for (token, dir) in iter_directives(s) {
645 if let Directive::Deferred { .. } = dir {
646 return Err(CliError::Config(format!(
647 "interpolation token `{token}` in {location} is not supported: \
648 `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
649 resolve only in source/sink configs"
650 )));
651 }
652 }
653 Ok(())
654 })
655}
656
657fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
658 let _ = walk_strings(value, &mut |s| {
659 for (token, dir) in iter_directives(s) {
660 if let Directive::Deferred { id, path } = dir {
661 if id == "now" {
665 continue;
666 }
667 out.push(DeferredRef {
668 referenced_id: id.to_owned(),
669 dotted_path: path.to_owned(),
670 token: token.to_owned(),
671 });
672 }
673 }
674 Ok(())
675 });
676}
677
678fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
679where
680 F: FnMut(&str) -> CliResult<()>,
681{
682 match value {
683 Value::String(s) => f(s),
684 Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
685 Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
686 _ => Ok(()),
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::config::{OnBatchErrorSpec, parse_with_extension};
694
695 fn cfg(yaml: &str) -> PipelineConfig {
696 parse_with_extension(yaml, "yaml").unwrap()
697 }
698
699 #[test]
700 fn implicit_single_row_when_matrix_absent() {
701 let c = cfg(r#"
702version: 1
703pipeline:
704 source: { type: rest, config: { base_url: https://x } }
705 sink: { type: jsonl, config: { path: ./o } }
706"#);
707 let nodes = expand(&c).unwrap();
708 assert_eq!(nodes.len(), 1);
709 assert_eq!(nodes[0].id, "row-0");
710 assert!(matches!(nodes[0].role, NodeRole::Root));
711 assert_eq!(nodes[0].source.kind, "rest");
712 assert_eq!(nodes[0].sink.kind, "jsonl");
713 }
714
715 #[test]
716 fn rejects_runtime_token_in_dlq_config() {
717 let c = cfg(r#"
721version: 1
722pipeline:
723 source: { type: rest, config: { base_url: https://x } }
724 sink: { type: jsonl, config: { path: ./o } }
725 dlq:
726 sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
727"#);
728 let err = expand(&c).unwrap_err();
729 assert!(
730 matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
731 "got: {err:?}"
732 );
733 }
734
735 #[test]
736 fn rejects_runtime_token_in_state_config() {
737 let c = cfg(r#"
738version: 1
739pipeline:
740 source: { type: rest, config: { base_url: https://x } }
741 sink: { type: jsonl, config: { path: ./o } }
742 state:
743 type: file
744 config: { path: "state-${now.date}" }
745"#);
746 let err = expand(&c).unwrap_err();
747 assert!(
748 matches!(&err, CliError::Config(m) if m.contains("state")),
749 "got: {err:?}"
750 );
751 }
752
753 #[test]
754 fn rejects_runtime_token_in_transform_config() {
755 let c = cfg(r#"
756version: 1
757pipeline:
758 source: { type: rest, config: { base_url: https://x } }
759 sink: { type: jsonl, config: { path: ./o } }
760 transforms:
761 - type: set
762 config: { field: ts, value: "${now.datetime}" }
763"#);
764 let err = expand(&c).unwrap_err();
765 assert!(
766 matches!(&err, CliError::Config(m) if m.contains("transform")),
767 "got: {err:?}"
768 );
769 }
770
771 #[test]
772 fn allows_runtime_token_in_source_and_sink_configs() {
773 let c = cfg(r#"
776version: 1
777pipeline:
778 source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
779 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
780"#);
781 let nodes = expand(&c).unwrap();
782 assert_eq!(nodes.len(), 1);
783 }
784
785 #[test]
786 fn merges_row_overrides_into_pipeline_source() {
787 let c = cfg(r#"
788version: 1
789pipeline:
790 source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
791 sink: { type: jsonl, config: { path: ./o } }
792matrix:
793 - id: users
794 source: { config: { path: /v1/users, headers: { b: 2 } } }
795"#);
796 let nodes = expand(&c).unwrap();
797 assert_eq!(nodes[0].id, "users");
798 assert_eq!(nodes[0].source.config["base_url"], "https://x");
799 assert_eq!(nodes[0].source.config["path"], "/v1/users");
800 assert_eq!(nodes[0].source.config["headers"]["a"], 1);
801 assert_eq!(nodes[0].source.config["headers"]["b"], 2);
802 }
803
804 #[test]
805 fn errors_on_unknown_parent() {
806 let c = cfg(r#"
807version: 1
808pipeline:
809 source: { type: rest, config: {} }
810 sink: { type: jsonl, config: { path: ./o } }
811matrix:
812 - id: child
813 parent: nobody
814"#);
815 assert!(matches!(
816 expand(&c).unwrap_err(),
817 CliError::UnknownParent { .. }
818 ));
819 }
820
821 #[test]
822 fn errors_on_duplicate_ids() {
823 let c = cfg(r#"
824version: 1
825pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
826matrix:
827 - { id: x }
828 - { id: x }
829"#);
830 assert!(matches!(
831 expand(&c).unwrap_err(),
832 CliError::DuplicateRowId { .. }
833 ));
834 }
835
836 #[test]
837 fn errors_on_reserved_id() {
838 let c = cfg(r#"
839version: 1
840pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
841matrix:
842 - { id: env }
843"#);
844 assert!(matches!(
845 expand(&c).unwrap_err(),
846 CliError::ReservedRowId { .. }
847 ));
848 }
849
850 #[test]
851 fn errors_on_self_parent_cycle() {
852 let c = cfg(r#"
853version: 1
854pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
855matrix:
856 - { id: a, parent: a }
857"#);
858 assert!(matches!(
859 expand(&c).unwrap_err(),
860 CliError::ParentCycle { .. }
861 ));
862 }
863
864 #[test]
865 fn errors_on_two_node_cycle() {
866 let c = cfg(r#"
867version: 1
868pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
869matrix:
870 - { id: a, parent: b }
871 - { id: b, parent: a }
872"#);
873 assert!(matches!(
874 expand(&c).unwrap_err(),
875 CliError::ParentCycle { .. }
876 ));
877 }
878
879 #[test]
880 fn errors_on_unknown_interpolation_id() {
881 let c = cfg(r#"
882version: 1
883pipeline:
884 source: { type: rest, config: { url: "https://x/${nobody.id}" } }
885 sink: { type: jsonl, config: { path: ./o } }
886"#);
887 assert!(matches!(
888 expand(&c).unwrap_err(),
889 CliError::UnknownInterpolationId { .. }
890 ));
891 }
892
893 #[test]
894 fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
895 let c = cfg(r#"
900version: 1
901pipeline:
902 source: { type: rest, config: { url: "https://x/${env.foo}" } }
903 sink: { type: jsonl, config: { path: ./o } }
904"#);
905 match expand(&c).unwrap_err() {
906 CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
907 other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
908 }
909 }
910
911 #[test]
912 fn accepts_id_path_when_referenced_row_exists() {
913 let c = cfg(r#"
914version: 1
915pipeline:
916 source: { type: rest, config: {} }
917 sink: { type: jsonl, config: { path: ./o } }
918matrix:
919 - id: users
920 - id: posts
921 parent: users
922 source: { config: { path: "/v1/users/${users.id}/posts" } }
923"#);
924 let nodes = expand(&c).unwrap();
925 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
926 assert_eq!(posts.deferred_refs.len(), 1);
927 assert_eq!(posts.deferred_refs[0].referenced_id, "users");
928 assert_eq!(posts.deferred_refs[0].dotted_path, "id");
929 }
930
931 #[test]
932 fn nested_referenced_path_resolves() {
933 let c = cfg(r#"
934version: 1
935pipeline:
936 source: { type: rest, config: {} }
937 sink: { type: jsonl, config: { path: ./o } }
938matrix:
939 - id: users
940 - id: addrs
941 parent: users
942 source: { config: { path: "/users/${users.addr.city}/addr" } }
943"#);
944 let nodes = expand(&c).unwrap();
945 let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
946 assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
947 }
948
949 #[test]
950 fn roots_come_before_children_in_order() {
951 let c = cfg(r#"
952version: 1
953pipeline:
954 source: { type: rest, config: {} }
955 sink: { type: jsonl, config: { path: ./o } }
956matrix:
957 - id: posts
958 parent: users
959 - id: users
960"#);
961 let nodes = expand(&c).unwrap();
962 let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
963 let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
964 assert!(users_idx < posts_idx, "users must precede posts");
965 }
966
967 #[test]
968 fn child_node_has_parent_role() {
969 let c = cfg(r#"
970version: 1
971pipeline:
972 source: { type: rest, config: {} }
973 sink: { type: jsonl, config: { path: ./o } }
974matrix:
975 - id: users
976 - id: posts
977 parent: users
978 parent_key: user_id
979"#);
980 let nodes = expand(&c).unwrap();
981 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
982 match &posts.role {
983 NodeRole::Child {
984 parent_id,
985 parent_key,
986 } => {
987 assert_eq!(parent_id, "users");
988 assert_eq!(parent_key, "user_id");
989 }
990 other => panic!("expected Child, got {other:?}"),
991 }
992 }
993
994 #[test]
995 fn expand_rejects_zero_per_page_budget() {
996 let yaml = r#"
997version: 1
998pipeline:
999 source: { type: rest, config: {} }
1000 sink: { type: jsonl, config: { path: ./o.jsonl } }
1001 dlq:
1002 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1003 max_failures_per_page: 0
1004"#;
1005 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1006 let err = expand(&cfg).unwrap_err();
1007 assert!(matches!(
1008 err,
1009 CliError::InvalidDlqBudget {
1010 field: "max_failures_per_page"
1011 }
1012 ));
1013 }
1014
1015 #[test]
1016 fn expand_rejects_zero_total_budget() {
1017 let yaml = r#"
1018version: 1
1019pipeline:
1020 source: { type: rest, config: {} }
1021 sink: { type: jsonl, config: { path: ./o.jsonl } }
1022 dlq:
1023 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1024 max_failures_total: 0
1025"#;
1026 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1027 let err = expand(&cfg).unwrap_err();
1028 assert!(matches!(
1029 err,
1030 CliError::InvalidDlqBudget {
1031 field: "max_failures_total"
1032 }
1033 ));
1034 }
1035
1036 #[test]
1037 fn expand_rejects_unknown_dlq_sink_kind() {
1038 let yaml = r#"
1039version: 1
1040pipeline:
1041 source: { type: rest, config: {} }
1042 sink: { type: jsonl, config: { path: ./o.jsonl } }
1043 dlq:
1044 sink: { type: not_a_sink, config: {} }
1045"#;
1046 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1047 let err = expand(&cfg).unwrap_err();
1048 assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
1049 }
1050
1051 #[cfg(feature = "quality")]
1052 #[test]
1053 fn expand_rejects_quarantine_without_dlq() {
1054 let yaml = r#"
1057version: 1
1058pipeline:
1059 source: { type: rest, config: {} }
1060 sink: { type: jsonl, config: { path: ./o.jsonl } }
1061 quality:
1062 record:
1063 - { type: not_null, field: id, on_failure: quarantine }
1064"#;
1065 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1066 let err = expand(&cfg).unwrap_err();
1067 match err {
1068 CliError::Config(msg) => {
1069 assert!(msg.contains("quarantine"), "{msg}");
1070 assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
1071 }
1072 other => panic!("expected Config error, got {other:?}"),
1073 }
1074 }
1075
1076 #[cfg(feature = "quality")]
1077 #[test]
1078 fn expand_accepts_quarantine_with_dlq() {
1079 let yaml = r#"
1080version: 1
1081pipeline:
1082 source: { type: rest, config: {} }
1083 sink: { type: jsonl, config: { path: ./o.jsonl } }
1084 dlq:
1085 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
1086 quality:
1087 record:
1088 - { type: not_null, field: id, on_failure: quarantine }
1089"#;
1090 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1091 let nodes = expand(&cfg).unwrap();
1092 assert_eq!(nodes.len(), 1);
1093 let q = nodes[0]
1094 .quality
1095 .as_ref()
1096 .expect("quality threaded onto node");
1097 assert_eq!(q.record.len(), 1);
1098 }
1099
1100 #[cfg(feature = "quality")]
1101 #[test]
1102 fn expand_accepts_abort_quality_without_dlq() {
1103 let yaml = r#"
1105version: 1
1106pipeline:
1107 source: { type: rest, config: {} }
1108 sink: { type: jsonl, config: { path: ./o.jsonl } }
1109 quality:
1110 record:
1111 - { type: not_null, field: id, on_failure: abort }
1112"#;
1113 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1114 let nodes = expand(&cfg).unwrap();
1115 assert!(nodes[0].quality.is_some());
1116 }
1117
1118 #[test]
1119 fn legacy_singular_source_resolves_as_default_template() {
1120 let c = cfg(r#"
1121version: 1
1122pipeline:
1123 source: { type: rest, config: { base_url: https://x } }
1124 sink: { type: jsonl, config: { path: ./o } }
1125"#);
1126 let nodes = expand(&c).unwrap();
1127 assert_eq!(nodes[0].source.kind, "rest");
1128 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1129 }
1130
1131 #[test]
1132 fn row_with_ref_picks_named_template() {
1133 let c = cfg(r#"
1134version: 1
1135pipeline:
1136 sources:
1137 users_api: { type: rest, config: { base_url: https://x } }
1138 sinks:
1139 archive: { type: jsonl, config: { path: ./out } }
1140matrix:
1141 - id: load_users
1142 source:
1143 ref: users_api
1144 config: { path: /v1/users }
1145 sink:
1146 ref: archive
1147 config: { path: ./users.jsonl }
1148"#);
1149 let nodes = expand(&c).unwrap();
1150 assert_eq!(nodes[0].source.kind, "rest");
1151 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1152 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1153 assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
1154 }
1155
1156 #[test]
1157 fn row_without_ref_falls_back_to_default_template() {
1158 let c = cfg(r#"
1159version: 1
1160pipeline:
1161 source: { type: rest, config: { base_url: https://x } }
1162 sink: { type: jsonl, config: { path: ./o } }
1163matrix:
1164 - id: users
1165 source: { config: { path: /v1/users } }
1166"#);
1167 let nodes = expand(&c).unwrap();
1168 assert_eq!(nodes[0].source.kind, "rest");
1169 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1170 }
1171
1172 #[test]
1173 fn unknown_template_ref_errors_with_known_list() {
1174 let c = cfg(r#"
1175version: 1
1176pipeline:
1177 sources:
1178 a: { type: rest, config: {} }
1179 b: { type: rest, config: {} }
1180 sinks:
1181 s: { type: jsonl, config: { path: ./o } }
1182matrix:
1183 - id: x
1184 source: { ref: c }
1185 sink: { ref: s }
1186"#);
1187 let err = expand(&c).unwrap_err();
1188 match err {
1189 CliError::UnknownTemplate {
1190 kind,
1191 name,
1192 row_id,
1193 known,
1194 } => {
1195 assert_eq!(kind, "source");
1196 assert_eq!(name, "c");
1197 assert_eq!(row_id, "x");
1198 assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1199 }
1200 other => panic!("expected UnknownTemplate, got {other:?}"),
1201 }
1202 }
1203
1204 #[test]
1205 fn missing_default_template_errors() {
1206 let c = cfg(r#"
1209version: 1
1210pipeline:
1211 sources:
1212 users_api: { type: rest, config: {} }
1213 sink: { type: jsonl, config: { path: ./o } }
1214matrix:
1215 - id: x
1216 source: { config: { path: /v1 } }
1217"#);
1218 let err = expand(&c).unwrap_err();
1219 match err {
1220 CliError::MissingTemplate { kind, row_id } => {
1221 assert_eq!(kind, "source");
1222 assert_eq!(row_id, "x");
1223 }
1224 other => panic!("expected MissingTemplate, got {other:?}"),
1225 }
1226 }
1227
1228 #[test]
1229 fn duplicate_default_template_errors() {
1230 let c = cfg(r#"
1232version: 1
1233pipeline:
1234 source: { type: rest, config: {} }
1235 sources:
1236 default: { type: rest, config: {} }
1237 sink: { type: jsonl, config: { path: ./o } }
1238"#);
1239 let err = expand(&c).unwrap_err();
1240 match err {
1241 CliError::DuplicateTemplate { kind, name } => {
1242 assert_eq!(kind, "source");
1243 assert_eq!(name, "default");
1244 }
1245 other => panic!("expected DuplicateTemplate, got {other:?}"),
1246 }
1247 }
1248
1249 #[test]
1250 fn row_can_override_template_kind() {
1251 let c = cfg(r#"
1252version: 1
1253pipeline:
1254 sources:
1255 api: { type: rest, config: { base_url: https://x } }
1256 sinks:
1257 out: { type: jsonl, config: { path: ./o } }
1258matrix:
1259 - id: x
1260 source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1261 sink: { ref: out }
1262"#);
1263 let nodes = expand(&c).unwrap();
1264 assert_eq!(nodes[0].source.kind, "graphql");
1265 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1266 assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1267 }
1268
1269 #[test]
1270 fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1271 let yaml = r#"
1272version: 1
1273pipeline:
1274 source: { type: rest, config: {} }
1275 sink: { type: jsonl, config: { path: ./o.jsonl } }
1276 dlq:
1277 sink: { type: jsonl, config: { path: ./base.jsonl } }
1278matrix:
1279 - id: a
1280 - id: b
1281 dlq: null
1282 - id: c
1283 dlq:
1284 sink: { type: jsonl, config: { path: ./c.jsonl } }
1285 on_batch_error: dlq_all
1286"#;
1287 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1288 let nodes = expand(&cfg).unwrap();
1289 assert_eq!(nodes.len(), 3);
1290 assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1292 assert_eq!(
1293 nodes[0]
1294 .dlq
1295 .as_ref()
1296 .unwrap()
1297 .sink
1298 .config
1299 .get("path")
1300 .unwrap(),
1301 "./base.jsonl"
1302 );
1303 assert!(nodes[1].dlq.is_none());
1305 assert_eq!(
1307 nodes[2].dlq.as_ref().unwrap().on_batch_error,
1308 OnBatchErrorSpec::DlqAll
1309 );
1310 assert_eq!(
1311 nodes[2]
1312 .dlq
1313 .as_ref()
1314 .unwrap()
1315 .sink
1316 .config
1317 .get("path")
1318 .unwrap(),
1319 "./c.jsonl"
1320 );
1321 }
1322
1323 #[test]
1324 fn multiple_rows_pick_different_templates() {
1325 let c = cfg(r#"
1326version: 1
1327pipeline:
1328 sources:
1329 users_api: { type: rest, config: { base_url: https://users.example } }
1330 orders_api: { type: rest, config: { base_url: https://orders.example } }
1331 sinks:
1332 archive: { type: jsonl, config: { path: ./out } }
1333matrix:
1334 - id: load_users
1335 source: { ref: users_api, config: { path: /v1/users } }
1336 sink: { ref: archive, config: { path: ./users.jsonl } }
1337 - id: load_orders
1338 source: { ref: orders_api, config: { path: /v1/orders } }
1339 sink: { ref: archive, config: { path: ./orders.jsonl } }
1340"#);
1341 let nodes = expand(&c).unwrap();
1342 assert_eq!(nodes.len(), 2);
1343 let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1344 let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1345 assert_eq!(users.source.config["base_url"], "https://users.example");
1346 assert_eq!(users.source.config["path"], "/v1/users");
1347 assert_eq!(orders.source.config["base_url"], "https://orders.example");
1348 assert_eq!(orders.source.config["path"], "/v1/orders");
1349 assert_eq!(users.sink.config["path"], "./users.jsonl");
1351 assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1352 }
1353
1354 #[test]
1355 fn sink_template_with_transforms_errors_at_expand() {
1356 let yaml = r#"
1357version: 1
1358pipeline:
1359 source:
1360 type: rest
1361 config: {}
1362 sinks:
1363 bad:
1364 type: jsonl
1365 config: { destination: /tmp/x.jsonl }
1366 transforms:
1367 - { type: flatten, config: { separator: "_" } }
1368matrix:
1369 - id: row
1370 sink: { ref: bad }
1371"#;
1372 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1373 .unwrap();
1374 let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1375 match err {
1376 crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1377 other => panic!("expected TransformsOnSink, got {other:?}"),
1378 }
1379 }
1380
1381 #[test]
1382 fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1383 let yaml = r#"
1384version: 1
1385pipeline:
1386 source:
1387 type: rest
1388 config: {}
1389 sinks:
1390 bad:
1391 type: jsonl
1392 config: { destination: /tmp/x.jsonl }
1393 inherit_transforms: false
1394matrix:
1395 - id: row
1396 sink: { ref: bad }
1397"#;
1398 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1399 .unwrap();
1400 let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1401 match err {
1402 crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1403 other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1404 }
1405 }
1406
1407 fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1408 transforms.iter().map(|t| t.kind.clone()).collect()
1409 }
1410
1411 #[test]
1412 fn three_layer_concat_default_inherit() {
1413 let yaml = r#"
1414version: 1
1415pipeline:
1416 transforms:
1417 - { type: flatten, config: { separator: "_" } }
1418 sources:
1419 s:
1420 type: rest
1421 config: {}
1422 transforms:
1423 - { type: keys_case, config: { mode: snake } }
1424 sink:
1425 type: jsonl
1426 config: { destination: /tmp/x.jsonl }
1427matrix:
1428 - id: row
1429 source: { ref: s }
1430 transforms:
1431 - { type: select, config: { fields: [id] } }
1432"#;
1433 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1434 .unwrap();
1435 let nodes = crate::expand::expand(&cfg).unwrap();
1436 assert_eq!(nodes.len(), 1);
1437 assert_eq!(
1438 kinds(&nodes[0].transforms),
1439 vec!["flatten", "keys_case", "select"]
1440 );
1441 }
1442
1443 #[test]
1444 fn source_inherit_false_drops_pipeline_layer() {
1445 let yaml = r#"
1446version: 1
1447pipeline:
1448 transforms:
1449 - { type: flatten, config: { separator: "_" } }
1450 sources:
1451 s:
1452 type: rest
1453 config: {}
1454 inherit_transforms: false
1455 transforms:
1456 - { type: keys_case, config: { mode: snake } }
1457 sink:
1458 type: jsonl
1459 config: { destination: /tmp/x.jsonl }
1460matrix:
1461 - id: row
1462 source: { ref: s }
1463 transforms:
1464 - { type: select, config: { fields: [id] } }
1465"#;
1466 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1467 .unwrap();
1468 let nodes = crate::expand::expand(&cfg).unwrap();
1469 assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1470 }
1471
1472 #[test]
1473 fn row_inherit_false_drops_pipeline_and_source_layers() {
1474 let yaml = r#"
1475version: 1
1476pipeline:
1477 transforms:
1478 - { type: flatten, config: { separator: "_" } }
1479 sources:
1480 s:
1481 type: rest
1482 config: {}
1483 transforms:
1484 - { type: keys_case, config: { mode: snake } }
1485 sink:
1486 type: jsonl
1487 config: { destination: /tmp/x.jsonl }
1488matrix:
1489 - id: row
1490 source: { ref: s }
1491 inherit_transforms: false
1492 transforms:
1493 - { type: select, config: { fields: [id] } }
1494"#;
1495 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1496 .unwrap();
1497 let nodes = crate::expand::expand(&cfg).unwrap();
1498 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1499 }
1500
1501 #[test]
1502 fn both_inherit_false_yields_row_only() {
1503 let yaml = r#"
1504version: 1
1505pipeline:
1506 transforms:
1507 - { type: flatten, config: { separator: "_" } }
1508 sources:
1509 s:
1510 type: rest
1511 config: {}
1512 inherit_transforms: false
1513 transforms:
1514 - { type: keys_case, config: { mode: snake } }
1515 sink:
1516 type: jsonl
1517 config: { destination: /tmp/x.jsonl }
1518matrix:
1519 - id: row
1520 source: { ref: s }
1521 inherit_transforms: false
1522 transforms:
1523 - { type: select, config: { fields: [id] } }
1524"#;
1525 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1526 .unwrap();
1527 let nodes = crate::expand::expand(&cfg).unwrap();
1528 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1529 }
1530
1531 #[test]
1532 fn all_layers_omitted_yields_empty_transforms() {
1533 let yaml = r#"
1534version: 1
1535pipeline:
1536 source:
1537 type: rest
1538 config: {}
1539 sink:
1540 type: jsonl
1541 config: { destination: /tmp/x.jsonl }
1542matrix:
1543 - id: row
1544"#;
1545 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1546 .unwrap();
1547 let nodes = crate::expand::expand(&cfg).unwrap();
1548 assert!(nodes[0].transforms.is_empty());
1549 }
1550
1551 #[test]
1552 fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
1553 let yaml = r#"
1555version: 1
1556pipeline:
1557 source: { type: rest, config: {} }
1558 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1559"#;
1560 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1561 assert!(expand(&cfg).is_ok());
1563 }
1564
1565 #[test]
1566 fn now_is_a_reserved_row_id() {
1567 let yaml = r#"
1568version: 1
1569pipeline:
1570 source: { type: rest, config: {} }
1571 sink: { type: jsonl, config: { path: ./o.jsonl } }
1572matrix:
1573 - id: now
1574"#;
1575 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1576 match expand(&cfg).unwrap_err() {
1577 CliError::ReservedRowId { id } => assert_eq!(id, "now"),
1578 other => panic!("expected ReservedRowId, got {other:?}"),
1579 }
1580 }
1581
1582 #[test]
1583 fn expand_rejects_invalid_adaptive_batch_size_at_load() {
1584 let yaml = r#"
1588version: 1
1589pipeline:
1590 source: { type: rest, config: {} }
1591 sink: { type: jsonl, config: { path: ./o.jsonl } }
1592execution:
1593 adaptive_batch_size:
1594 enabled: true
1595 min: 5000
1596 max: 100
1597"#;
1598 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1599 let err = expand(&cfg).unwrap_err();
1600 assert!(
1601 err.to_string().contains("adaptive_batch_size.min"),
1602 "expected adaptive validation error, got: {err}"
1603 );
1604 }
1605
1606 #[test]
1607 fn expand_accepts_valid_adaptive_batch_size() {
1608 let yaml = r#"
1609version: 1
1610pipeline:
1611 source: { type: rest, config: {} }
1612 sink: { type: jsonl, config: { path: ./o.jsonl } }
1613execution:
1614 adaptive_batch_size:
1615 enabled: true
1616 min: 100
1617 max: 5000
1618 target_latency_ms: 500
1619"#;
1620 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1621 assert!(expand(&cfg).is_ok());
1622 }
1623
1624 #[test]
1627 fn exactly_once_rejects_non_cdc_source() {
1628 let yaml = r#"
1630version: 1
1631delivery: exactly_once
1632pipeline:
1633 source: { type: rest, config: { base_url: https://x } }
1634 sink: { type: stdout, config: {} }
1635 state:
1636 type: memory
1637 config: {}
1638"#;
1639 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1640 let err = expand(&cfg).unwrap_err();
1641 match &err {
1642 CliError::Config(msg) => {
1643 assert!(
1644 msg.contains("rest"),
1645 "expected source kind in error, got: {msg}"
1646 );
1647 assert!(
1648 msg.contains("exactly_once") || msg.contains("not supported"),
1649 "got: {msg}"
1650 );
1651 }
1652 other => panic!("expected Config error, got {other:?}"),
1653 }
1654 }
1655
1656 #[test]
1657 fn exactly_once_rejects_non_idempotent_sink() {
1658 let yaml = r#"
1660version: 1
1661delivery: exactly_once
1662pipeline:
1663 source: { type: postgres-cdc, config: {} }
1664 sink: { type: stdout, config: {} }
1665 state:
1666 type: memory
1667 config: {}
1668"#;
1669 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1670 let err = expand(&cfg).unwrap_err();
1671 match &err {
1672 CliError::Config(msg) => {
1673 assert!(
1674 msg.contains("stdout"),
1675 "expected sink kind in error, got: {msg}"
1676 );
1677 assert!(
1678 msg.contains("exactly_once") || msg.contains("not supported"),
1679 "got: {msg}"
1680 );
1681 }
1682 other => panic!("expected Config error, got {other:?}"),
1683 }
1684 }
1685
1686 #[test]
1687 fn exactly_once_accepted_with_cdc_source_idempotent_sink_and_state() {
1688 let yaml = r#"
1692version: 1
1693delivery: exactly_once
1694pipeline:
1695 source: { type: postgres-cdc, config: {} }
1696 sink: { type: sqlite, config: {} }
1697 state:
1698 type: file
1699 config: { path: "/tmp/faucet-eo-state.json" }
1700"#;
1701 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1702 let nodes = expand(&cfg).unwrap();
1703 assert_eq!(nodes.len(), 1);
1704 assert_eq!(nodes[0].delivery, faucet_core::DeliveryMode::ExactlyOnce);
1705 }
1706
1707 #[test]
1708 fn exactly_once_rejects_memory_state() {
1709 let yaml = r#"
1712version: 1
1713delivery: exactly_once
1714pipeline:
1715 source: { type: postgres-cdc, config: {} }
1716 sink: { type: sqlite, config: {} }
1717 state:
1718 type: memory
1719 config: {}
1720"#;
1721 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1722 let err = expand(&cfg).unwrap_err();
1723 match &err {
1724 CliError::Config(msg) => assert!(
1725 msg.contains("durable") && msg.contains("memory"),
1726 "expected durable/memory mention, got: {msg}"
1727 ),
1728 other => panic!("expected Config error, got {other:?}"),
1729 }
1730 }
1731
1732 #[test]
1733 fn exactly_once_rejects_missing_state_store() {
1734 let yaml = r#"
1736version: 1
1737delivery: exactly_once
1738pipeline:
1739 source: { type: postgres-cdc, config: {} }
1740 sink: { type: sqlite, config: {} }
1741"#;
1742 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1743 let err = expand(&cfg).unwrap_err();
1744 match &err {
1745 CliError::Config(msg) => {
1746 assert!(
1747 msg.contains("state store") || msg.contains("state"),
1748 "expected state-store mention in error, got: {msg}"
1749 );
1750 }
1751 other => panic!("expected Config error, got {other:?}"),
1752 }
1753 }
1754
1755 #[test]
1756 fn rejects_upsert_on_unsupported_sink() {
1757 let c = cfg(r#"
1758version: 1
1759name: t
1760pipeline:
1761 source: { type: rest, config: { url: "http://x" } }
1762 sink: { type: jsonl, config: { path: "out.jsonl", write_mode: upsert, key: [id] } }
1763"#);
1764 let err = expand(&c).unwrap_err();
1765 let msg = format!("{err}");
1766 assert!(
1767 msg.contains("write_mode") && msg.contains("upsert") && msg.contains("jsonl"),
1768 "{msg}"
1769 );
1770 }
1771
1772 #[test]
1773 fn rejects_upsert_without_key() {
1774 let c = cfg(r#"
1775version: 1
1776name: t
1777pipeline:
1778 source: { type: rest, config: { url: "http://x" } }
1779 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert } }
1780"#);
1781 let err = expand(&c).unwrap_err();
1782 let msg = format!("{err}");
1783 assert!(msg.contains("key"), "{msg}");
1784 }
1785
1786 #[test]
1787 fn accepts_upsert_on_postgres_with_key() {
1788 let c = cfg(r#"
1789version: 1
1790name: t
1791pipeline:
1792 source: { type: rest, config: { url: "http://x" } }
1793 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: upsert, key: [id] } }
1794"#);
1795 assert!(expand(&c).is_ok());
1796 }
1797
1798 #[test]
1799 fn bigquery_upsert_passes_write_mode_gate() {
1800 let c = cfg(r#"
1801version: 1
1802name: t
1803pipeline:
1804 source: { type: rest, config: { url: "http://x" } }
1805 sink: { type: bigquery, config: { project_id: p, dataset_id: d, table_id: t, auth: { type: application_default }, write_mode: upsert, key: [id] } }
1806"#);
1807 assert!(expand(&c).is_ok());
1808 }
1809
1810 #[test]
1811 fn accepts_append_by_default_on_any_sink() {
1812 let c = cfg(r#"
1813version: 1
1814name: t
1815pipeline:
1816 source: { type: rest, config: { url: "http://x" } }
1817 sink: { type: jsonl, config: { path: "out.jsonl" } }
1818"#);
1819 assert!(expand(&c).is_ok());
1820 }
1821
1822 #[test]
1823 fn rejects_delete_without_key() {
1824 let c = cfg(r#"
1825version: 1
1826name: t
1827pipeline:
1828 source: { type: rest, config: { url: "http://x" } }
1829 sink: { type: mongodb, config: { connection_url: "mongodb://x", database: d, collection: c, write_mode: delete } }
1830"#);
1831 let err = expand(&c).unwrap_err();
1832 let msg = format!("{err}");
1833 assert!(msg.contains("delete") && msg.contains("key"), "{msg}");
1834 }
1835
1836 #[test]
1837 fn rejects_unknown_write_mode() {
1838 let c = cfg(r#"
1839version: 1
1840name: t
1841pipeline:
1842 source: { type: rest, config: { url: "http://x" } }
1843 sink: { type: postgres, config: { connection_url: "postgres://x", table_name: t, column_mapping: auto_map, write_mode: replace } }
1844"#);
1845 let err = expand(&c).unwrap_err();
1846 let msg = format!("{err}");
1847 assert!(
1848 msg.contains("unknown write_mode") && msg.contains("replace"),
1849 "{msg}"
1850 );
1851 }
1852
1853 #[test]
1854 fn rejects_poison_dlq_action_without_dlq() {
1855 let c = cfg(r#"
1856version: 1
1857pipeline:
1858 source: { type: rest, config: { base_url: https://x } }
1859 sink: { type: jsonl, config: { path: ./o } }
1860resilience:
1861 poison: { max_row_attempts: 3, action: dlq }
1862"#);
1863 let err = expand(&c).unwrap_err();
1864 assert!(
1865 matches!(&err, CliError::Config(m) if m.contains("poison.action=dlq") && m.contains("dlq:")),
1866 "got: {err:?}"
1867 );
1868 }
1869
1870 #[test]
1871 fn accepts_poison_dlq_action_with_dlq() {
1872 let c = cfg(r#"
1873version: 1
1874pipeline:
1875 source: { type: rest, config: { base_url: https://x } }
1876 sink: { type: jsonl, config: { path: ./o } }
1877 dlq:
1878 sink: { type: jsonl, config: { path: ./dead.jsonl } }
1879resilience:
1880 poison: { max_row_attempts: 3, action: dlq }
1881"#);
1882 let nodes = expand(&c).expect("poison.action=dlq with a dlq: block should validate");
1883 assert_eq!(nodes.len(), 1);
1884 }
1885
1886 #[test]
1887 fn accepts_poison_drop_action_without_dlq() {
1888 let c = cfg(r#"
1890version: 1
1891pipeline:
1892 source: { type: rest, config: { base_url: https://x } }
1893 sink: { type: jsonl, config: { path: ./o } }
1894resilience:
1895 poison: { max_row_attempts: 3, action: drop }
1896"#);
1897 let nodes = expand(&c).expect("poison.action=drop needs no dlq");
1898 assert_eq!(nodes.len(), 1);
1899 }
1900
1901 #[test]
1904 fn evolve_on_non_evolvable_sink_rejected() {
1905 let c = cfg(r#"
1907version: 1
1908pipeline:
1909 source: { type: rest, config: { base_url: https://x } }
1910 sink: { type: jsonl, config: { path: ./o.jsonl } }
1911 schema:
1912 on_drift: evolve
1913"#);
1914 let err = expand(&c).unwrap_err();
1915 match &err {
1916 CliError::Config(msg) => {
1917 assert!(
1918 msg.contains("evolve"),
1919 "expected evolve mention, got: {msg}"
1920 );
1921 assert!(msg.contains("jsonl"), "expected sink kind, got: {msg}");
1922 }
1923 other => panic!("expected Config error, got {other:?}"),
1924 }
1925 }
1926
1927 #[test]
1928 fn quarantine_drift_without_dlq_rejected() {
1929 let c = cfg(r#"
1931version: 1
1932pipeline:
1933 source: { type: rest, config: { base_url: https://x } }
1934 sink: { type: postgres, config: {} }
1935 schema:
1936 on_drift: quarantine
1937"#);
1938 let err = expand(&c).unwrap_err();
1939 match &err {
1940 CliError::Config(msg) => {
1941 assert!(
1942 msg.contains("quarantine"),
1943 "expected quarantine mention, got: {msg}"
1944 );
1945 assert!(msg.contains("dlq") || msg.contains("DLQ"), "got: {msg}");
1946 }
1947 other => panic!("expected Config error, got {other:?}"),
1948 }
1949 }
1950
1951 #[test]
1952 fn evolve_on_postgres_passes() {
1953 let c = cfg(r#"
1955version: 1
1956pipeline:
1957 source: { type: rest, config: { base_url: https://x } }
1958 sink: { type: postgres, config: {} }
1959 schema:
1960 on_drift: evolve
1961"#);
1962 assert!(expand(&c).is_ok());
1963 }
1964}