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