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 deferred_refs: Vec<DeferredRef>,
49}
50
51#[derive(Debug, Clone)]
52pub enum NodeRole {
53 Root,
55 Child {
57 parent_id: String,
58 parent_key: String,
59 },
60}
61
62#[derive(Debug, Clone)]
63pub struct DeferredRef {
64 pub referenced_id: String,
65 pub dotted_path: String,
66 pub token: String,
67}
68
69struct Registry<'a> {
73 sources: HashMap<&'a str, &'a ConnectorSpec>,
74 sinks: HashMap<&'a str, &'a ConnectorSpec>,
75}
76
77impl<'a> Registry<'a> {
78 fn build(spec: &'a PipelineSpec) -> CliResult<Self> {
79 let mut sources: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
80 if let Some(default) = spec.source.as_ref() {
81 sources.insert("default", default);
82 }
83 for (name, s) in spec.sources.iter() {
84 if sources.contains_key(name.as_str()) {
85 return Err(CliError::DuplicateTemplate {
86 kind: "source",
87 name: name.clone(),
88 });
89 }
90 sources.insert(name.as_str(), s);
91 }
92
93 let mut sinks: HashMap<&'a str, &'a ConnectorSpec> = HashMap::new();
94 if let Some(default) = spec.sink.as_ref() {
95 if default.transforms.is_some() {
96 return Err(CliError::TransformsOnSink {
97 name: "default".to_string(),
98 });
99 }
100 if !default.inherit_transforms {
101 return Err(CliError::InheritTransformsOnSink {
102 name: "default".to_string(),
103 });
104 }
105 sinks.insert("default", default);
106 }
107 for (name, s) in spec.sinks.iter() {
108 if sinks.contains_key(name.as_str()) {
109 return Err(CliError::DuplicateTemplate {
110 kind: "sink",
111 name: name.clone(),
112 });
113 }
114 if s.transforms.is_some() {
115 return Err(CliError::TransformsOnSink { name: name.clone() });
116 }
117 if !s.inherit_transforms {
118 return Err(CliError::InheritTransformsOnSink { name: name.clone() });
119 }
120 sinks.insert(name.as_str(), s);
121 }
122 Ok(Self { sources, sinks })
123 }
124
125 fn known(&self, kind: &'static str) -> Vec<String> {
126 debug_assert!(
127 matches!(kind, "source" | "sink"),
128 "Registry::known called with kind = {:?}",
129 kind
130 );
131 let map = if kind == "source" {
132 &self.sources
133 } else {
134 &self.sinks
135 };
136 let mut out: Vec<String> = map.keys().map(|s| (*s).to_string()).collect();
137 out.sort();
138 out
139 }
140
141 fn resolve(
142 &self,
143 kind: &'static str,
144 row_id: &str,
145 overlay: Option<&PartialConnector>,
146 ) -> CliResult<ConnectorSpec> {
147 debug_assert!(
148 matches!(kind, "source" | "sink"),
149 "Registry::resolve called with kind = {:?}",
150 kind
151 );
152 let map = if kind == "source" {
153 &self.sources
154 } else {
155 &self.sinks
156 };
157 let ref_name = overlay
158 .and_then(|p| p.r#ref.as_deref())
159 .unwrap_or("default");
160 let base = map.get(ref_name).ok_or_else(|| {
161 if ref_name == "default" {
162 CliError::MissingTemplate {
163 kind,
164 row_id: row_id.to_owned(),
165 }
166 } else {
167 CliError::UnknownTemplate {
168 kind,
169 name: ref_name.to_owned(),
170 row_id: row_id.to_owned(),
171 known: self.known(kind),
172 }
173 }
174 })?;
175 let mut out = (*base).clone();
176 if let Some(p) = overlay {
177 if let Some(k) = &p.kind {
178 out.kind = k.clone();
179 }
180 if let Some(c) = &p.config {
181 merge_value(&mut out.config, c.clone());
182 }
183 }
184 Ok(out)
185 }
186}
187
188pub fn expand(cfg: &PipelineConfig) -> CliResult<Vec<ExpandedNode>> {
191 if let Some(ab) = cfg
196 .execution
197 .as_ref()
198 .and_then(|e| e.adaptive_batch_size.as_ref())
199 {
200 ab.validate()?;
203 }
204
205 let synthetic_row;
207 let rows: &[MatrixRow] = if cfg.matrix.is_empty() {
208 synthetic_row = [MatrixRow {
209 id: None,
210 parent: None,
211 parent_key: "id".into(),
212 source: None,
213 sink: None,
214 transforms: None,
215 inherit_transforms: true,
216 state: None,
217 dlq: None,
218 }];
219 &synthetic_row
220 } else {
221 &cfg.matrix
222 };
223
224 let mut ids: Vec<String> = Vec::with_capacity(rows.len());
226 let mut seen: HashSet<String> = HashSet::new();
227 for (i, row) in rows.iter().enumerate() {
228 let id = match &row.id {
229 Some(s) => s.clone(),
230 None => format!("row-{i}"),
231 };
232 if RESERVED_IDS.contains(&id.as_str()) {
233 return Err(CliError::ReservedRowId { id });
234 }
235 if !seen.insert(id.clone()) {
236 return Err(CliError::DuplicateRowId { id });
237 }
238 ids.push(id);
239 }
240 let id_set: HashSet<&str> = ids.iter().map(String::as_str).collect();
241
242 let mut parents: HashMap<&str, &str> = HashMap::new();
244 for (i, row) in rows.iter().enumerate() {
245 let id = ids[i].as_str();
246 if let Some(parent) = row.parent.as_deref() {
247 if !id_set.contains(parent) {
248 return Err(CliError::UnknownParent {
249 id: id.to_owned(),
250 parent: parent.to_owned(),
251 });
252 }
253 if parent == id {
254 return Err(CliError::ParentCycle {
255 ids: vec![id.to_owned()],
256 });
257 }
258 parents.insert(id, parent);
259 }
260 }
261 detect_cycle(&parents)?;
262
263 for (i, row) in rows.iter().enumerate() {
267 let id = ids[i].as_str();
268 if let Some(p) = &row.source
269 && let Some(c) = &p.config
270 {
271 check_refs(c, &id_set, id)?;
272 }
273 if let Some(p) = &row.sink
274 && let Some(c) = &p.config
275 {
276 check_refs(c, &id_set, id)?;
277 }
278 }
279 if let Some(s) = &cfg.pipeline.source {
280 check_refs(&s.config, &id_set, "pipeline.source")?;
281 }
282 if let Some(s) = &cfg.pipeline.sink {
283 check_refs(&s.config, &id_set, "pipeline.sink")?;
284 }
285 for (name, s) in &cfg.pipeline.sources {
286 check_refs(&s.config, &id_set, &format!("pipeline.sources.{name}"))?;
287 }
288 for (name, s) in &cfg.pipeline.sinks {
289 check_refs(&s.config, &id_set, &format!("pipeline.sinks.{name}"))?;
290 }
291
292 let registry = Registry::build(&cfg.pipeline)?;
294
295 let mut by_parent: HashMap<&str, Vec<usize>> = HashMap::new();
298 let mut roots: Vec<usize> = Vec::new();
299 for (i, row) in rows.iter().enumerate() {
300 match row.parent.as_deref() {
301 None => roots.push(i),
302 Some(p) => by_parent.entry(p).or_default().push(i),
303 }
304 }
305
306 let mut order: Vec<usize> = Vec::with_capacity(rows.len());
307 let mut queue: std::collections::VecDeque<usize> = roots.into_iter().collect();
308 while let Some(idx) = queue.pop_front() {
309 order.push(idx);
310 if let Some(children) = by_parent.get(ids[idx].as_str()) {
311 queue.extend(children.iter().copied());
312 }
313 }
314 debug_assert_eq!(order.len(), rows.len());
315
316 let mut out = Vec::with_capacity(rows.len());
317 for &i in &order {
318 let row = &rows[i];
319 let row_id = ids[i].as_str();
320 let merged_source = registry.resolve("source", row_id, row.source.as_ref())?;
321 let merged_sink = registry.resolve("sink", row_id, row.sink.as_ref())?;
322 let role = match &row.parent {
323 None => NodeRole::Root,
324 Some(p) => NodeRole::Child {
325 parent_id: p.clone(),
326 parent_key: row.parent_key.clone(),
327 },
328 };
329 let mut deferred = Vec::new();
330 collect_deferred(&merged_source.config, &mut deferred);
331 collect_deferred(&merged_sink.config, &mut deferred);
332
333 let src_inherit = merged_source.inherit_transforms;
338 let row_inherit = row.inherit_transforms;
339 let mut transforms: Vec<TransformSpec> = Vec::new();
340 if src_inherit && row_inherit {
341 transforms.extend(cfg.pipeline.transforms.iter().cloned());
342 }
343 if row_inherit && let Some(src_ts) = merged_source.transforms.as_ref() {
344 transforms.extend(src_ts.iter().cloned());
345 }
346 if let Some(row_ts) = row.transforms.as_ref() {
347 transforms.extend(row_ts.iter().cloned());
348 }
349 let state = row.state.clone().or_else(|| cfg.pipeline.state.clone());
350 let dlq = match row.dlq.clone() {
354 Some(None) => None,
355 Some(Some(spec)) => Some(spec),
356 None => cfg.pipeline.dlq.clone(),
357 };
358
359 if let Some(ref d) = dlq {
360 if matches!(d.max_failures_per_page, Some(0)) {
361 return Err(CliError::InvalidDlqBudget {
362 field: "max_failures_per_page",
363 });
364 }
365 if matches!(d.max_failures_total, Some(0)) {
366 return Err(CliError::InvalidDlqBudget {
367 field: "max_failures_total",
368 });
369 }
370 if !crate::registry::sink_exists(&d.sink.kind) {
371 return Err(CliError::UnknownDlqSinkKind {
372 kind: d.sink.kind.clone(),
373 context: format!("row `{row_id}`"),
374 });
375 }
376 }
377
378 for (ti, t) in transforms.iter().enumerate() {
383 reject_runtime_tokens(
384 &t.config,
385 &format!("row `{row_id}` transform[{ti}] (`{}`)", t.kind),
386 )?;
387 }
388 if let Some(ref st) = state {
389 reject_runtime_tokens(&st.config, &format!("row `{row_id}` state config"))?;
390 }
391 if let Some(ref d) = dlq {
392 reject_runtime_tokens(&d.sink.config, &format!("row `{row_id}` dlq sink config"))?;
393 }
394
395 #[cfg(feature = "quality")]
402 let quality = cfg.pipeline.quality.clone();
403 #[cfg(feature = "quality")]
404 if let Some(ref spec) = quality {
405 let compiled = faucet_core::CompiledQuality::compile(spec)
406 .map_err(|e| CliError::Config(format!("quality (row `{row_id}`): {e}")))?;
407 if compiled.requires_dlq() && dlq.is_none() {
408 return Err(CliError::Config(format!(
409 "row `{row_id}`: a quality check uses `on_failure: quarantine` \
410 but no DLQ is configured — add a `dlq:` block (or change the \
411 check's `on_failure` to `abort`)"
412 )));
413 }
414 }
415
416 out.push(ExpandedNode {
417 id: ids[i].clone(),
418 row_index: i,
419 role,
420 source: merged_source,
421 sink: merged_sink,
422 transforms,
423 state,
424 dlq,
425 #[cfg(feature = "quality")]
426 quality,
427 deferred_refs: deferred,
428 });
429 }
430 Ok(out)
431}
432
433fn detect_cycle(parents: &HashMap<&str, &str>) -> CliResult<()> {
434 for &start in parents.keys() {
437 let mut visited: BTreeSet<&str> = BTreeSet::new();
438 let mut cur = start;
439 while let Some(&p) = parents.get(cur) {
440 if !visited.insert(cur) {
441 let chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
442 return Err(CliError::ParentCycle { ids: chain });
443 }
444 cur = p;
445 if cur == start {
446 let mut chain: Vec<String> = visited.iter().map(|s| (*s).to_string()).collect();
447 chain.push(start.to_string());
448 return Err(CliError::ParentCycle { ids: chain });
449 }
450 }
451 }
452 Ok(())
453}
454
455fn check_refs(value: &Value, id_set: &HashSet<&str>, owner: &str) -> CliResult<()> {
459 walk_strings(value, &mut |s| {
460 for (token, dir) in iter_directives(s) {
461 if let Directive::Deferred { id, .. } = dir
466 && id != "now"
467 && !id_set.contains(id)
468 {
469 return Err(CliError::UnknownInterpolationId {
470 id: id.to_owned(),
471 token: format!("{token} (in {owner})"),
472 });
473 }
474 }
475 Ok(())
476 })
477}
478
479fn reject_runtime_tokens(value: &Value, location: &str) -> CliResult<()> {
487 walk_strings(value, &mut |s| {
488 for (token, dir) in iter_directives(s) {
489 if let Directive::Deferred { .. } = dir {
490 return Err(CliError::Config(format!(
491 "interpolation token `{token}` in {location} is not supported: \
492 `${{...}}` runtime tokens (parent-record references and `${{now.*}}`) \
493 resolve only in source/sink configs"
494 )));
495 }
496 }
497 Ok(())
498 })
499}
500
501fn collect_deferred(value: &Value, out: &mut Vec<DeferredRef>) {
502 let _ = walk_strings(value, &mut |s| {
503 for (token, dir) in iter_directives(s) {
504 if let Directive::Deferred { id, path } = dir {
505 if id == "now" {
509 continue;
510 }
511 out.push(DeferredRef {
512 referenced_id: id.to_owned(),
513 dotted_path: path.to_owned(),
514 token: token.to_owned(),
515 });
516 }
517 }
518 Ok(())
519 });
520}
521
522fn walk_strings<F>(value: &Value, f: &mut F) -> CliResult<()>
523where
524 F: FnMut(&str) -> CliResult<()>,
525{
526 match value {
527 Value::String(s) => f(s),
528 Value::Array(a) => a.iter().try_for_each(|v| walk_strings(v, f)),
529 Value::Object(m) => m.values().try_for_each(|v| walk_strings(v, f)),
530 _ => Ok(()),
531 }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::config::{OnBatchErrorSpec, parse_with_extension};
538
539 fn cfg(yaml: &str) -> PipelineConfig {
540 parse_with_extension(yaml, "yaml").unwrap()
541 }
542
543 #[test]
544 fn implicit_single_row_when_matrix_absent() {
545 let c = cfg(r#"
546version: 1
547pipeline:
548 source: { type: rest, config: { base_url: https://x } }
549 sink: { type: jsonl, config: { path: ./o } }
550"#);
551 let nodes = expand(&c).unwrap();
552 assert_eq!(nodes.len(), 1);
553 assert_eq!(nodes[0].id, "row-0");
554 assert!(matches!(nodes[0].role, NodeRole::Root));
555 assert_eq!(nodes[0].source.kind, "rest");
556 assert_eq!(nodes[0].sink.kind, "jsonl");
557 }
558
559 #[test]
560 fn rejects_runtime_token_in_dlq_config() {
561 let c = cfg(r#"
565version: 1
566pipeline:
567 source: { type: rest, config: { base_url: https://x } }
568 sink: { type: jsonl, config: { path: ./o } }
569 dlq:
570 sink: { type: jsonl, config: { path: "dead-${now.date}.jsonl" } }
571"#);
572 let err = expand(&c).unwrap_err();
573 assert!(
574 matches!(&err, CliError::Config(m) if m.contains("now.date") && m.contains("dlq")),
575 "got: {err:?}"
576 );
577 }
578
579 #[test]
580 fn rejects_runtime_token_in_state_config() {
581 let c = cfg(r#"
582version: 1
583pipeline:
584 source: { type: rest, config: { base_url: https://x } }
585 sink: { type: jsonl, config: { path: ./o } }
586 state:
587 type: file
588 config: { path: "state-${now.date}" }
589"#);
590 let err = expand(&c).unwrap_err();
591 assert!(
592 matches!(&err, CliError::Config(m) if m.contains("state")),
593 "got: {err:?}"
594 );
595 }
596
597 #[test]
598 fn rejects_runtime_token_in_transform_config() {
599 let c = cfg(r#"
600version: 1
601pipeline:
602 source: { type: rest, config: { base_url: https://x } }
603 sink: { type: jsonl, config: { path: ./o } }
604 transforms:
605 - type: set
606 config: { field: ts, value: "${now.datetime}" }
607"#);
608 let err = expand(&c).unwrap_err();
609 assert!(
610 matches!(&err, CliError::Config(m) if m.contains("transform")),
611 "got: {err:?}"
612 );
613 }
614
615 #[test]
616 fn allows_runtime_token_in_source_and_sink_configs() {
617 let c = cfg(r#"
620version: 1
621pipeline:
622 source: { type: rest, config: { base_url: "https://x?d=${now.date}" } }
623 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
624"#);
625 let nodes = expand(&c).unwrap();
626 assert_eq!(nodes.len(), 1);
627 }
628
629 #[test]
630 fn merges_row_overrides_into_pipeline_source() {
631 let c = cfg(r#"
632version: 1
633pipeline:
634 source: { type: rest, config: { base_url: https://x, headers: { a: 1 } } }
635 sink: { type: jsonl, config: { path: ./o } }
636matrix:
637 - id: users
638 source: { config: { path: /v1/users, headers: { b: 2 } } }
639"#);
640 let nodes = expand(&c).unwrap();
641 assert_eq!(nodes[0].id, "users");
642 assert_eq!(nodes[0].source.config["base_url"], "https://x");
643 assert_eq!(nodes[0].source.config["path"], "/v1/users");
644 assert_eq!(nodes[0].source.config["headers"]["a"], 1);
645 assert_eq!(nodes[0].source.config["headers"]["b"], 2);
646 }
647
648 #[test]
649 fn errors_on_unknown_parent() {
650 let c = cfg(r#"
651version: 1
652pipeline:
653 source: { type: rest, config: {} }
654 sink: { type: jsonl, config: { path: ./o } }
655matrix:
656 - id: child
657 parent: nobody
658"#);
659 assert!(matches!(
660 expand(&c).unwrap_err(),
661 CliError::UnknownParent { .. }
662 ));
663 }
664
665 #[test]
666 fn errors_on_duplicate_ids() {
667 let c = cfg(r#"
668version: 1
669pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
670matrix:
671 - { id: x }
672 - { id: x }
673"#);
674 assert!(matches!(
675 expand(&c).unwrap_err(),
676 CliError::DuplicateRowId { .. }
677 ));
678 }
679
680 #[test]
681 fn errors_on_reserved_id() {
682 let c = cfg(r#"
683version: 1
684pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
685matrix:
686 - { id: env }
687"#);
688 assert!(matches!(
689 expand(&c).unwrap_err(),
690 CliError::ReservedRowId { .. }
691 ));
692 }
693
694 #[test]
695 fn errors_on_self_parent_cycle() {
696 let c = cfg(r#"
697version: 1
698pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
699matrix:
700 - { id: a, parent: a }
701"#);
702 assert!(matches!(
703 expand(&c).unwrap_err(),
704 CliError::ParentCycle { .. }
705 ));
706 }
707
708 #[test]
709 fn errors_on_two_node_cycle() {
710 let c = cfg(r#"
711version: 1
712pipeline: { source: { type: rest, config: {} }, sink: { type: jsonl, config: { path: ./o } } }
713matrix:
714 - { id: a, parent: b }
715 - { id: b, parent: a }
716"#);
717 assert!(matches!(
718 expand(&c).unwrap_err(),
719 CliError::ParentCycle { .. }
720 ));
721 }
722
723 #[test]
724 fn errors_on_unknown_interpolation_id() {
725 let c = cfg(r#"
726version: 1
727pipeline:
728 source: { type: rest, config: { url: "https://x/${nobody.id}" } }
729 sink: { type: jsonl, config: { path: ./o } }
730"#);
731 assert!(matches!(
732 expand(&c).unwrap_err(),
733 CliError::UnknownInterpolationId { .. }
734 ));
735 }
736
737 #[test]
738 fn dot_form_reserved_prefix_is_validated_as_deferred_id() {
739 let c = cfg(r#"
744version: 1
745pipeline:
746 source: { type: rest, config: { url: "https://x/${env.foo}" } }
747 sink: { type: jsonl, config: { path: ./o } }
748"#);
749 match expand(&c).unwrap_err() {
750 CliError::UnknownInterpolationId { id, .. } => assert_eq!(id, "env"),
751 other => panic!("expected UnknownInterpolationId for `env`, got {other:?}"),
752 }
753 }
754
755 #[test]
756 fn accepts_id_path_when_referenced_row_exists() {
757 let c = cfg(r#"
758version: 1
759pipeline:
760 source: { type: rest, config: {} }
761 sink: { type: jsonl, config: { path: ./o } }
762matrix:
763 - id: users
764 - id: posts
765 parent: users
766 source: { config: { path: "/v1/users/${users.id}/posts" } }
767"#);
768 let nodes = expand(&c).unwrap();
769 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
770 assert_eq!(posts.deferred_refs.len(), 1);
771 assert_eq!(posts.deferred_refs[0].referenced_id, "users");
772 assert_eq!(posts.deferred_refs[0].dotted_path, "id");
773 }
774
775 #[test]
776 fn nested_referenced_path_resolves() {
777 let c = cfg(r#"
778version: 1
779pipeline:
780 source: { type: rest, config: {} }
781 sink: { type: jsonl, config: { path: ./o } }
782matrix:
783 - id: users
784 - id: addrs
785 parent: users
786 source: { config: { path: "/users/${users.addr.city}/addr" } }
787"#);
788 let nodes = expand(&c).unwrap();
789 let addrs = nodes.iter().find(|n| n.id == "addrs").unwrap();
790 assert_eq!(addrs.deferred_refs[0].dotted_path, "addr.city");
791 }
792
793 #[test]
794 fn roots_come_before_children_in_order() {
795 let c = cfg(r#"
796version: 1
797pipeline:
798 source: { type: rest, config: {} }
799 sink: { type: jsonl, config: { path: ./o } }
800matrix:
801 - id: posts
802 parent: users
803 - id: users
804"#);
805 let nodes = expand(&c).unwrap();
806 let users_idx = nodes.iter().position(|n| n.id == "users").unwrap();
807 let posts_idx = nodes.iter().position(|n| n.id == "posts").unwrap();
808 assert!(users_idx < posts_idx, "users must precede posts");
809 }
810
811 #[test]
812 fn child_node_has_parent_role() {
813 let c = cfg(r#"
814version: 1
815pipeline:
816 source: { type: rest, config: {} }
817 sink: { type: jsonl, config: { path: ./o } }
818matrix:
819 - id: users
820 - id: posts
821 parent: users
822 parent_key: user_id
823"#);
824 let nodes = expand(&c).unwrap();
825 let posts = nodes.iter().find(|n| n.id == "posts").unwrap();
826 match &posts.role {
827 NodeRole::Child {
828 parent_id,
829 parent_key,
830 } => {
831 assert_eq!(parent_id, "users");
832 assert_eq!(parent_key, "user_id");
833 }
834 other => panic!("expected Child, got {other:?}"),
835 }
836 }
837
838 #[test]
839 fn expand_rejects_zero_per_page_budget() {
840 let yaml = r#"
841version: 1
842pipeline:
843 source: { type: rest, config: {} }
844 sink: { type: jsonl, config: { path: ./o.jsonl } }
845 dlq:
846 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
847 max_failures_per_page: 0
848"#;
849 let cfg = parse_with_extension(yaml, "yaml").unwrap();
850 let err = expand(&cfg).unwrap_err();
851 assert!(matches!(
852 err,
853 CliError::InvalidDlqBudget {
854 field: "max_failures_per_page"
855 }
856 ));
857 }
858
859 #[test]
860 fn expand_rejects_zero_total_budget() {
861 let yaml = r#"
862version: 1
863pipeline:
864 source: { type: rest, config: {} }
865 sink: { type: jsonl, config: { path: ./o.jsonl } }
866 dlq:
867 sink: { type: jsonl, config: { path: ./dlq.jsonl } }
868 max_failures_total: 0
869"#;
870 let cfg = parse_with_extension(yaml, "yaml").unwrap();
871 let err = expand(&cfg).unwrap_err();
872 assert!(matches!(
873 err,
874 CliError::InvalidDlqBudget {
875 field: "max_failures_total"
876 }
877 ));
878 }
879
880 #[test]
881 fn expand_rejects_unknown_dlq_sink_kind() {
882 let yaml = r#"
883version: 1
884pipeline:
885 source: { type: rest, config: {} }
886 sink: { type: jsonl, config: { path: ./o.jsonl } }
887 dlq:
888 sink: { type: not_a_sink, config: {} }
889"#;
890 let cfg = parse_with_extension(yaml, "yaml").unwrap();
891 let err = expand(&cfg).unwrap_err();
892 assert!(matches!(err, CliError::UnknownDlqSinkKind { .. }));
893 }
894
895 #[cfg(feature = "quality")]
896 #[test]
897 fn expand_rejects_quarantine_without_dlq() {
898 let yaml = r#"
901version: 1
902pipeline:
903 source: { type: rest, config: {} }
904 sink: { type: jsonl, config: { path: ./o.jsonl } }
905 quality:
906 record:
907 - { type: not_null, field: id, on_failure: quarantine }
908"#;
909 let cfg = parse_with_extension(yaml, "yaml").unwrap();
910 let err = expand(&cfg).unwrap_err();
911 match err {
912 CliError::Config(msg) => {
913 assert!(msg.contains("quarantine"), "{msg}");
914 assert!(msg.contains("DLQ") || msg.contains("dlq"), "{msg}");
915 }
916 other => panic!("expected Config error, got {other:?}"),
917 }
918 }
919
920 #[cfg(feature = "quality")]
921 #[test]
922 fn expand_accepts_quarantine_with_dlq() {
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 quality:
931 record:
932 - { type: not_null, field: id, on_failure: quarantine }
933"#;
934 let cfg = parse_with_extension(yaml, "yaml").unwrap();
935 let nodes = expand(&cfg).unwrap();
936 assert_eq!(nodes.len(), 1);
937 let q = nodes[0]
938 .quality
939 .as_ref()
940 .expect("quality threaded onto node");
941 assert_eq!(q.record.len(), 1);
942 }
943
944 #[cfg(feature = "quality")]
945 #[test]
946 fn expand_accepts_abort_quality_without_dlq() {
947 let yaml = r#"
949version: 1
950pipeline:
951 source: { type: rest, config: {} }
952 sink: { type: jsonl, config: { path: ./o.jsonl } }
953 quality:
954 record:
955 - { type: not_null, field: id, on_failure: abort }
956"#;
957 let cfg = parse_with_extension(yaml, "yaml").unwrap();
958 let nodes = expand(&cfg).unwrap();
959 assert!(nodes[0].quality.is_some());
960 }
961
962 #[test]
963 fn legacy_singular_source_resolves_as_default_template() {
964 let c = cfg(r#"
965version: 1
966pipeline:
967 source: { type: rest, config: { base_url: https://x } }
968 sink: { type: jsonl, config: { path: ./o } }
969"#);
970 let nodes = expand(&c).unwrap();
971 assert_eq!(nodes[0].source.kind, "rest");
972 assert_eq!(nodes[0].source.config["base_url"], "https://x");
973 }
974
975 #[test]
976 fn row_with_ref_picks_named_template() {
977 let c = cfg(r#"
978version: 1
979pipeline:
980 sources:
981 users_api: { type: rest, config: { base_url: https://x } }
982 sinks:
983 archive: { type: jsonl, config: { path: ./out } }
984matrix:
985 - id: load_users
986 source:
987 ref: users_api
988 config: { path: /v1/users }
989 sink:
990 ref: archive
991 config: { path: ./users.jsonl }
992"#);
993 let nodes = expand(&c).unwrap();
994 assert_eq!(nodes[0].source.kind, "rest");
995 assert_eq!(nodes[0].source.config["base_url"], "https://x");
996 assert_eq!(nodes[0].source.config["path"], "/v1/users");
997 assert_eq!(nodes[0].sink.config["path"], "./users.jsonl");
998 }
999
1000 #[test]
1001 fn row_without_ref_falls_back_to_default_template() {
1002 let c = cfg(r#"
1003version: 1
1004pipeline:
1005 source: { type: rest, config: { base_url: https://x } }
1006 sink: { type: jsonl, config: { path: ./o } }
1007matrix:
1008 - id: users
1009 source: { config: { path: /v1/users } }
1010"#);
1011 let nodes = expand(&c).unwrap();
1012 assert_eq!(nodes[0].source.kind, "rest");
1013 assert_eq!(nodes[0].source.config["path"], "/v1/users");
1014 }
1015
1016 #[test]
1017 fn unknown_template_ref_errors_with_known_list() {
1018 let c = cfg(r#"
1019version: 1
1020pipeline:
1021 sources:
1022 a: { type: rest, config: {} }
1023 b: { type: rest, config: {} }
1024 sinks:
1025 s: { type: jsonl, config: { path: ./o } }
1026matrix:
1027 - id: x
1028 source: { ref: c }
1029 sink: { ref: s }
1030"#);
1031 let err = expand(&c).unwrap_err();
1032 match err {
1033 CliError::UnknownTemplate {
1034 kind,
1035 name,
1036 row_id,
1037 known,
1038 } => {
1039 assert_eq!(kind, "source");
1040 assert_eq!(name, "c");
1041 assert_eq!(row_id, "x");
1042 assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
1043 }
1044 other => panic!("expected UnknownTemplate, got {other:?}"),
1045 }
1046 }
1047
1048 #[test]
1049 fn missing_default_template_errors() {
1050 let c = cfg(r#"
1053version: 1
1054pipeline:
1055 sources:
1056 users_api: { type: rest, config: {} }
1057 sink: { type: jsonl, config: { path: ./o } }
1058matrix:
1059 - id: x
1060 source: { config: { path: /v1 } }
1061"#);
1062 let err = expand(&c).unwrap_err();
1063 match err {
1064 CliError::MissingTemplate { kind, row_id } => {
1065 assert_eq!(kind, "source");
1066 assert_eq!(row_id, "x");
1067 }
1068 other => panic!("expected MissingTemplate, got {other:?}"),
1069 }
1070 }
1071
1072 #[test]
1073 fn duplicate_default_template_errors() {
1074 let c = cfg(r#"
1076version: 1
1077pipeline:
1078 source: { type: rest, config: {} }
1079 sources:
1080 default: { type: rest, config: {} }
1081 sink: { type: jsonl, config: { path: ./o } }
1082"#);
1083 let err = expand(&c).unwrap_err();
1084 match err {
1085 CliError::DuplicateTemplate { kind, name } => {
1086 assert_eq!(kind, "source");
1087 assert_eq!(name, "default");
1088 }
1089 other => panic!("expected DuplicateTemplate, got {other:?}"),
1090 }
1091 }
1092
1093 #[test]
1094 fn row_can_override_template_kind() {
1095 let c = cfg(r#"
1096version: 1
1097pipeline:
1098 sources:
1099 api: { type: rest, config: { base_url: https://x } }
1100 sinks:
1101 out: { type: jsonl, config: { path: ./o } }
1102matrix:
1103 - id: x
1104 source: { ref: api, type: graphql, config: { query: "{users{id}}" } }
1105 sink: { ref: out }
1106"#);
1107 let nodes = expand(&c).unwrap();
1108 assert_eq!(nodes[0].source.kind, "graphql");
1109 assert_eq!(nodes[0].source.config["base_url"], "https://x");
1110 assert_eq!(nodes[0].source.config["query"], "{users{id}}");
1111 }
1112
1113 #[test]
1114 fn expand_accepts_inherited_disabled_replaced_dlq_rows() {
1115 let yaml = r#"
1116version: 1
1117pipeline:
1118 source: { type: rest, config: {} }
1119 sink: { type: jsonl, config: { path: ./o.jsonl } }
1120 dlq:
1121 sink: { type: jsonl, config: { path: ./base.jsonl } }
1122matrix:
1123 - id: a
1124 - id: b
1125 dlq: null
1126 - id: c
1127 dlq:
1128 sink: { type: jsonl, config: { path: ./c.jsonl } }
1129 on_batch_error: dlq_all
1130"#;
1131 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1132 let nodes = expand(&cfg).unwrap();
1133 assert_eq!(nodes.len(), 3);
1134 assert_eq!(nodes[0].dlq.as_ref().unwrap().sink.kind, "jsonl");
1136 assert_eq!(
1137 nodes[0]
1138 .dlq
1139 .as_ref()
1140 .unwrap()
1141 .sink
1142 .config
1143 .get("path")
1144 .unwrap(),
1145 "./base.jsonl"
1146 );
1147 assert!(nodes[1].dlq.is_none());
1149 assert_eq!(
1151 nodes[2].dlq.as_ref().unwrap().on_batch_error,
1152 OnBatchErrorSpec::DlqAll
1153 );
1154 assert_eq!(
1155 nodes[2]
1156 .dlq
1157 .as_ref()
1158 .unwrap()
1159 .sink
1160 .config
1161 .get("path")
1162 .unwrap(),
1163 "./c.jsonl"
1164 );
1165 }
1166
1167 #[test]
1168 fn multiple_rows_pick_different_templates() {
1169 let c = cfg(r#"
1170version: 1
1171pipeline:
1172 sources:
1173 users_api: { type: rest, config: { base_url: https://users.example } }
1174 orders_api: { type: rest, config: { base_url: https://orders.example } }
1175 sinks:
1176 archive: { type: jsonl, config: { path: ./out } }
1177matrix:
1178 - id: load_users
1179 source: { ref: users_api, config: { path: /v1/users } }
1180 sink: { ref: archive, config: { path: ./users.jsonl } }
1181 - id: load_orders
1182 source: { ref: orders_api, config: { path: /v1/orders } }
1183 sink: { ref: archive, config: { path: ./orders.jsonl } }
1184"#);
1185 let nodes = expand(&c).unwrap();
1186 assert_eq!(nodes.len(), 2);
1187 let users = nodes.iter().find(|n| n.id == "load_users").unwrap();
1188 let orders = nodes.iter().find(|n| n.id == "load_orders").unwrap();
1189 assert_eq!(users.source.config["base_url"], "https://users.example");
1190 assert_eq!(users.source.config["path"], "/v1/users");
1191 assert_eq!(orders.source.config["base_url"], "https://orders.example");
1192 assert_eq!(orders.source.config["path"], "/v1/orders");
1193 assert_eq!(users.sink.config["path"], "./users.jsonl");
1195 assert_eq!(orders.sink.config["path"], "./orders.jsonl");
1196 }
1197
1198 #[test]
1199 fn sink_template_with_transforms_errors_at_expand() {
1200 let yaml = r#"
1201version: 1
1202pipeline:
1203 source:
1204 type: rest
1205 config: {}
1206 sinks:
1207 bad:
1208 type: jsonl
1209 config: { destination: /tmp/x.jsonl }
1210 transforms:
1211 - { type: flatten, config: { separator: "_" } }
1212matrix:
1213 - id: row
1214 sink: { ref: bad }
1215"#;
1216 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1217 .unwrap();
1218 let err = crate::expand::expand(&cfg).expect_err("expected TransformsOnSink");
1219 match err {
1220 crate::error::CliError::TransformsOnSink { name } => assert_eq!(name, "bad"),
1221 other => panic!("expected TransformsOnSink, got {other:?}"),
1222 }
1223 }
1224
1225 #[test]
1226 fn sink_template_with_inherit_transforms_false_errors_at_expand() {
1227 let yaml = r#"
1228version: 1
1229pipeline:
1230 source:
1231 type: rest
1232 config: {}
1233 sinks:
1234 bad:
1235 type: jsonl
1236 config: { destination: /tmp/x.jsonl }
1237 inherit_transforms: false
1238matrix:
1239 - id: row
1240 sink: { ref: bad }
1241"#;
1242 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1243 .unwrap();
1244 let err = crate::expand::expand(&cfg).expect_err("expected InheritTransformsOnSink");
1245 match err {
1246 crate::error::CliError::InheritTransformsOnSink { name } => assert_eq!(name, "bad"),
1247 other => panic!("expected InheritTransformsOnSink, got {other:?}"),
1248 }
1249 }
1250
1251 fn kinds(transforms: &[crate::config::TransformSpec]) -> Vec<String> {
1252 transforms.iter().map(|t| t.kind.clone()).collect()
1253 }
1254
1255 #[test]
1256 fn three_layer_concat_default_inherit() {
1257 let yaml = r#"
1258version: 1
1259pipeline:
1260 transforms:
1261 - { type: flatten, config: { separator: "_" } }
1262 sources:
1263 s:
1264 type: rest
1265 config: {}
1266 transforms:
1267 - { type: keys_case, config: { mode: snake } }
1268 sink:
1269 type: jsonl
1270 config: { destination: /tmp/x.jsonl }
1271matrix:
1272 - id: row
1273 source: { ref: s }
1274 transforms:
1275 - { type: select, config: { fields: [id] } }
1276"#;
1277 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1278 .unwrap();
1279 let nodes = crate::expand::expand(&cfg).unwrap();
1280 assert_eq!(nodes.len(), 1);
1281 assert_eq!(
1282 kinds(&nodes[0].transforms),
1283 vec!["flatten", "keys_case", "select"]
1284 );
1285 }
1286
1287 #[test]
1288 fn source_inherit_false_drops_pipeline_layer() {
1289 let yaml = r#"
1290version: 1
1291pipeline:
1292 transforms:
1293 - { type: flatten, config: { separator: "_" } }
1294 sources:
1295 s:
1296 type: rest
1297 config: {}
1298 inherit_transforms: false
1299 transforms:
1300 - { type: keys_case, config: { mode: snake } }
1301 sink:
1302 type: jsonl
1303 config: { destination: /tmp/x.jsonl }
1304matrix:
1305 - id: row
1306 source: { ref: s }
1307 transforms:
1308 - { type: select, config: { fields: [id] } }
1309"#;
1310 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1311 .unwrap();
1312 let nodes = crate::expand::expand(&cfg).unwrap();
1313 assert_eq!(kinds(&nodes[0].transforms), vec!["keys_case", "select"]);
1314 }
1315
1316 #[test]
1317 fn row_inherit_false_drops_pipeline_and_source_layers() {
1318 let yaml = r#"
1319version: 1
1320pipeline:
1321 transforms:
1322 - { type: flatten, config: { separator: "_" } }
1323 sources:
1324 s:
1325 type: rest
1326 config: {}
1327 transforms:
1328 - { type: keys_case, config: { mode: snake } }
1329 sink:
1330 type: jsonl
1331 config: { destination: /tmp/x.jsonl }
1332matrix:
1333 - id: row
1334 source: { ref: s }
1335 inherit_transforms: false
1336 transforms:
1337 - { type: select, config: { fields: [id] } }
1338"#;
1339 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1340 .unwrap();
1341 let nodes = crate::expand::expand(&cfg).unwrap();
1342 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1343 }
1344
1345 #[test]
1346 fn both_inherit_false_yields_row_only() {
1347 let yaml = r#"
1348version: 1
1349pipeline:
1350 transforms:
1351 - { type: flatten, config: { separator: "_" } }
1352 sources:
1353 s:
1354 type: rest
1355 config: {}
1356 inherit_transforms: false
1357 transforms:
1358 - { type: keys_case, config: { mode: snake } }
1359 sink:
1360 type: jsonl
1361 config: { destination: /tmp/x.jsonl }
1362matrix:
1363 - id: row
1364 source: { ref: s }
1365 inherit_transforms: false
1366 transforms:
1367 - { type: select, config: { fields: [id] } }
1368"#;
1369 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1370 .unwrap();
1371 let nodes = crate::expand::expand(&cfg).unwrap();
1372 assert_eq!(kinds(&nodes[0].transforms), vec!["select"]);
1373 }
1374
1375 #[test]
1376 fn all_layers_omitted_yields_empty_transforms() {
1377 let yaml = r#"
1378version: 1
1379pipeline:
1380 source:
1381 type: rest
1382 config: {}
1383 sink:
1384 type: jsonl
1385 config: { destination: /tmp/x.jsonl }
1386matrix:
1387 - id: row
1388"#;
1389 let cfg = crate::config::PipelineConfig::from_text(yaml, std::path::Path::new("test.yaml"))
1390 .unwrap();
1391 let nodes = crate::expand::expand(&cfg).unwrap();
1392 assert!(nodes[0].transforms.is_empty());
1393 }
1394
1395 #[test]
1396 fn now_is_a_valid_builtin_ref_not_an_unknown_id() {
1397 let yaml = r#"
1399version: 1
1400pipeline:
1401 source: { type: rest, config: {} }
1402 sink: { type: jsonl, config: { path: "out-${now.date}.jsonl" } }
1403"#;
1404 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1405 assert!(expand(&cfg).is_ok());
1407 }
1408
1409 #[test]
1410 fn now_is_a_reserved_row_id() {
1411 let yaml = r#"
1412version: 1
1413pipeline:
1414 source: { type: rest, config: {} }
1415 sink: { type: jsonl, config: { path: ./o.jsonl } }
1416matrix:
1417 - id: now
1418"#;
1419 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1420 match expand(&cfg).unwrap_err() {
1421 CliError::ReservedRowId { id } => assert_eq!(id, "now"),
1422 other => panic!("expected ReservedRowId, got {other:?}"),
1423 }
1424 }
1425
1426 #[test]
1427 fn expand_rejects_invalid_adaptive_batch_size_at_load() {
1428 let yaml = r#"
1432version: 1
1433pipeline:
1434 source: { type: rest, config: {} }
1435 sink: { type: jsonl, config: { path: ./o.jsonl } }
1436execution:
1437 adaptive_batch_size:
1438 enabled: true
1439 min: 5000
1440 max: 100
1441"#;
1442 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1443 let err = expand(&cfg).unwrap_err();
1444 assert!(
1445 err.to_string().contains("adaptive_batch_size.min"),
1446 "expected adaptive validation error, got: {err}"
1447 );
1448 }
1449
1450 #[test]
1451 fn expand_accepts_valid_adaptive_batch_size() {
1452 let yaml = r#"
1453version: 1
1454pipeline:
1455 source: { type: rest, config: {} }
1456 sink: { type: jsonl, config: { path: ./o.jsonl } }
1457execution:
1458 adaptive_batch_size:
1459 enabled: true
1460 min: 100
1461 max: 5000
1462 target_latency_ms: 500
1463"#;
1464 let cfg = parse_with_extension(yaml, "yaml").unwrap();
1465 assert!(expand(&cfg).is_ok());
1466 }
1467}