1use crate::error::{CliError, CliResult};
25use chrono::{DateTime, FixedOffset};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::path::PathBuf;
29
30pub fn interpolate(input: &str) -> CliResult<String> {
34 rewrite(input, |body| match classify_directive(body) {
35 Directive::LoadTime { prefix, body } => match prefix {
36 "env" | "secret" => {
37 let value = std::env::var(body).map_err(|_| CliError::MissingEnvVar {
38 var: body.to_owned(),
39 location: format!("${{{prefix}:{body}}}"),
40 })?;
41 crate::secrets::registry::register(&value);
48 Ok(Some(value))
49 }
50 "file" => {
51 let value = read_file_trimmed(body)?;
52 crate::secrets::registry::register(&value);
53 Ok(Some(value))
54 }
55 _ => Ok(None),
58 },
59 Directive::Deferred { .. } => Ok(None),
60 })
61}
62
63pub fn interpolate_record(input: &str, ctx: &HashMap<String, Value>) -> CliResult<String> {
69 rewrite(input, |body| match classify_directive(body) {
70 Directive::LoadTime { .. } => Ok(None),
71 Directive::Deferred { id, path } => {
72 let record = ctx
73 .get(id)
74 .ok_or_else(|| CliError::UnknownInterpolationId {
75 id: id.to_owned(),
76 token: format!("${{{body}}}"),
77 })?;
78 let resolved =
79 resolve_dotted(record, path).ok_or_else(|| CliError::MissingRecordField {
80 id: id.to_owned(),
81 path: path.to_owned(),
82 })?;
83 Ok(Some(value_to_string(&resolved)))
84 }
85 })
86}
87
88pub fn resolve_now(input: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
93 rewrite(input, |body| {
94 if let Directive::Deferred { id: "now", path } = classify_directive(body) {
95 return Ok(Some(now_token(path, clock)?));
96 }
97 Ok(None)
98 })
99}
100
101fn now_token(path: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
103 if let Some(fmt) = path.strip_prefix("strftime.") {
105 use chrono::format::{Item, StrftimeItems};
108 let items: Vec<Item> = StrftimeItems::new(fmt).collect();
109 if items.iter().any(|i| matches!(i, Item::Error)) {
110 return Err(CliError::Config(format!(
111 "invalid strftime format in `${{now.strftime.{fmt}}}`"
112 )));
113 }
114 return Ok(clock.format_with_items(items.iter()).to_string());
115 }
116 let rendered = match path {
117 "date" => clock.format("%Y-%m-%d").to_string(),
118 "datetime" | "iso" => clock.to_rfc3339(),
119 "year" => clock.format("%Y").to_string(),
120 "month" => clock.format("%m").to_string(),
121 "day" => clock.format("%d").to_string(),
122 "hour" => clock.format("%H").to_string(),
123 "minute" => clock.format("%M").to_string(),
124 "second" => clock.format("%S").to_string(),
125 "unix" => clock.timestamp().to_string(),
126 other => {
127 return Err(CliError::Config(format!(
128 "unknown `${{now.{other}}}` token — valid: date, datetime, iso, year, month, day, hour, minute, second, unix, strftime.<fmt>"
129 )));
130 }
131 };
132 Ok(rendered)
133}
134
135pub(crate) fn rewrite<F>(input: &str, mut resolve: F) -> CliResult<String>
138where
139 F: FnMut(&str) -> CliResult<Option<String>>,
140{
141 let mut out = String::with_capacity(input.len());
142 let bytes = input.as_bytes();
143 let mut i = 0;
144 while i < bytes.len() {
145 if bytes[i] == b'$' && i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
147 out.push('$');
148 i += 2;
149 continue;
150 }
151 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
152 let start = i + 2;
153 let Some(rel_end) = input[start..].find('}') else {
154 out.push_str(&input[i..]);
156 break;
157 };
158 let end = start + rel_end;
159 let body = &input[start..end];
160 match resolve(body)? {
161 Some(s) => out.push_str(&s),
162 None => out.push_str(&input[i..=end]),
163 }
164 i = end + 1;
165 continue;
166 }
167 let ch = input[i..].chars().next().unwrap();
168 out.push(ch);
169 i += ch.len_utf8();
170 }
171 Ok(out)
172}
173
174pub enum Directive<'a> {
185 LoadTime { prefix: &'a str, body: &'a str },
187 Deferred { id: &'a str, path: &'a str },
190}
191
192pub fn classify_directive(body: &str) -> Directive<'_> {
193 match body.split_once(':') {
194 Some((prefix, rest)) => Directive::LoadTime { prefix, body: rest },
195 None => {
196 let (id, path) = body.split_once('.').unwrap_or((body, ""));
197 Directive::Deferred { id, path }
198 }
199 }
200}
201
202pub fn iter_directives(s: &str) -> impl Iterator<Item = (&str, Directive<'_>)> {
208 let bytes = s.as_bytes();
209 let mut i = 0;
210 std::iter::from_fn(move || {
211 while i < bytes.len() {
212 if bytes[i] == b'$'
214 && i + 2 < bytes.len()
215 && bytes[i + 1] == b'$'
216 && bytes[i + 2] == b'{'
217 {
218 i += 2;
219 continue;
220 }
221 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
222 let start = i;
223 let body_start = i + 2;
224 let rel_end = s[body_start..].find('}')?;
225 let end = body_start + rel_end;
226 i = end + 1;
227 let body = &s[body_start..end];
228 return Some((&s[start..=end], classify_directive(body)));
229 }
230 i += 1;
231 }
232 None
233 })
234}
235
236const MAX_INTERPOLATED_FILE_BYTES: u64 = 1024 * 1024; fn read_file_trimmed(path_str: &str) -> CliResult<String> {
244 use std::io::Read as _;
245 let path = PathBuf::from(path_str);
246 let file = std::fs::File::open(&path).map_err(|source| CliError::ReadInterpolatedFile {
247 path: path.clone(),
248 source,
249 })?;
250 let mut buf = Vec::new();
253 file.take(MAX_INTERPOLATED_FILE_BYTES + 1)
254 .read_to_end(&mut buf)
255 .map_err(|source| CliError::ReadInterpolatedFile {
256 path: path.clone(),
257 source,
258 })?;
259 if buf.len() as u64 > MAX_INTERPOLATED_FILE_BYTES {
260 return Err(CliError::InterpolatedFileTooLarge {
261 path,
262 max_bytes: MAX_INTERPOLATED_FILE_BYTES,
263 });
264 }
265 Ok(String::from_utf8_lossy(&buf).trim_end().to_owned())
266}
267
268fn resolve_dotted(root: &Value, path: &str) -> Option<Value> {
271 if path.is_empty() {
272 return Some(root.clone());
273 }
274 let mut cur = root;
275 for segment in path.split('.') {
276 cur = match cur {
277 Value::Object(map) => map.get(segment)?,
278 Value::Array(arr) => {
279 let idx: usize = segment.parse().ok()?;
280 arr.get(idx)?
281 }
282 _ => return None,
283 };
284 }
285 Some(cur.clone())
286}
287
288fn value_to_string(v: &Value) -> String {
292 match v {
293 Value::String(s) => s.clone(),
294 other => other.to_string(),
295 }
296}
297
298pub fn resolve_config_refs(cfg: &mut crate::config::PipelineConfig) -> CliResult<()> {
318 if let Some(vars) = cfg.vars.clone() {
320 let resolved = resolve_vars_block(&vars)?;
321 cfg.vars = Some(resolved);
322 }
323
324 let empty_vars: HashMap<String, Value> = HashMap::new();
325 let vars_ref: &HashMap<String, Value> = cfg.vars.as_ref().unwrap_or(&empty_vars);
326
327 for (_name, spec) in cfg.pipeline.sources.iter_mut() {
330 resolve_vars_only(&mut spec.config, vars_ref)?;
331 }
332 for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
333 resolve_vars_only(&mut spec.config, vars_ref)?;
334 }
335 if let Some(spec) = cfg.pipeline.source.as_mut() {
336 resolve_vars_only(&mut spec.config, vars_ref)?;
337 }
338 if let Some(spec) = cfg.pipeline.sink.as_mut() {
339 resolve_vars_only(&mut spec.config, vars_ref)?;
340 }
341
342 let snapshot = TemplateSnapshot::capture(&cfg.pipeline);
344
345 for (_name, spec) in cfg.pipeline.sources.iter_mut() {
348 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
349 }
350 for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
351 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
352 }
353 if let Some(spec) = cfg.pipeline.source.as_mut() {
354 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
355 }
356 if let Some(spec) = cfg.pipeline.sink.as_mut() {
357 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
358 }
359 for t in cfg.pipeline.transforms.iter_mut() {
360 resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
361 }
362 if let Some(s) = cfg.pipeline.state.as_mut() {
363 resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
364 }
365 if let Some(d) = cfg.pipeline.dlq.as_mut() {
366 resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
367 }
368 if let Some(auth) = cfg.auth.as_mut() {
371 for (_name, spec) in auth.iter_mut() {
372 resolve_value_full(spec, vars_ref, &snapshot)?;
373 }
374 }
375 for (i, row) in cfg.matrix.iter_mut().enumerate() {
376 let _row_owner = row.id.clone().unwrap_or_else(|| format!("row-{i}"));
377 if let Some(p) = row.source.as_mut()
378 && let Some(c) = p.config.as_mut()
379 {
380 resolve_value_full(c, vars_ref, &snapshot)?;
381 }
382 if let Some(p) = row.sink.as_mut()
383 && let Some(c) = p.config.as_mut()
384 {
385 resolve_value_full(c, vars_ref, &snapshot)?;
386 }
387 if let Some(ts) = row.transforms.as_mut() {
388 for t in ts.iter_mut() {
389 resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
390 }
391 }
392 if let Some(s) = row.state.as_mut() {
393 resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
394 }
395 if let Some(Some(d)) = row.dlq.as_mut() {
396 resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
397 }
398 }
399 Ok(())
400}
401
402struct TemplateSnapshot {
405 sources: HashMap<String, Value>,
406 sinks: HashMap<String, Value>,
407}
408
409impl TemplateSnapshot {
410 fn capture(spec: &crate::config::PipelineSpec) -> Self {
411 let mut sources: HashMap<String, Value> = spec
412 .sources
413 .iter()
414 .map(|(k, v)| {
415 (
416 k.clone(),
417 serde_json::to_value(v)
418 .expect("ConnectorSpec derives Serialize and cannot fail"),
419 )
420 })
421 .collect();
422 if let Some(s) = &spec.source {
423 sources.entry("default".into()).or_insert_with(|| {
424 serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
425 });
426 }
427 let mut sinks: HashMap<String, Value> = spec
428 .sinks
429 .iter()
430 .map(|(k, v)| {
431 (
432 k.clone(),
433 serde_json::to_value(v)
434 .expect("ConnectorSpec derives Serialize and cannot fail"),
435 )
436 })
437 .collect();
438 if let Some(s) = &spec.sink {
439 sinks.entry("default".into()).or_insert_with(|| {
440 serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
441 });
442 }
443 Self { sources, sinks }
444 }
445}
446
447fn resolve_vars_block(input: &HashMap<String, Value>) -> CliResult<HashMap<String, Value>> {
450 let mut resolved: HashMap<String, Value> = HashMap::new();
451 let mut visiting: Vec<String> = Vec::new();
452 for key in input.keys() {
453 resolve_one_var(key, input, &mut resolved, &mut visiting)?;
454 }
455 Ok(resolved)
456}
457
458fn resolve_one_var(
459 key: &str,
460 input: &HashMap<String, Value>,
461 resolved: &mut HashMap<String, Value>,
462 visiting: &mut Vec<String>,
463) -> CliResult<()> {
464 if resolved.contains_key(key) {
465 return Ok(());
466 }
467 if let Some(start) = visiting.iter().position(|k| k == key) {
468 let chain: Vec<String> = visiting[start..]
472 .iter()
473 .map(|k| format!("vars.{k}"))
474 .chain(std::iter::once(format!("vars.{key}")))
475 .collect();
476 return Err(CliError::InterpolationCycle { chain });
477 }
478 visiting.push(key.to_string());
479 let mut value = input
480 .get(key)
481 .expect("key was taken from input map")
482 .clone();
483 resolve_vars_recursive(&mut value, input, resolved, visiting)?;
484 visiting.pop();
485 resolved.insert(key.to_string(), value);
486 Ok(())
487}
488
489fn resolve_vars_recursive(
492 v: &mut Value,
493 input: &HashMap<String, Value>,
494 resolved: &mut HashMap<String, Value>,
495 visiting: &mut Vec<String>,
496) -> CliResult<()> {
497 match v {
498 Value::String(s) => {
499 let new_s = rewrite(s, |body| {
500 let Some(name) = body.strip_prefix("vars.") else {
501 return Ok(None); };
503 if !resolved.contains_key(name) {
504 if !input.contains_key(name) {
505 return Err(CliError::UnknownVarsRef {
506 name: name.to_string(),
507 token: format!("${{{body}}}"),
508 });
509 }
510 resolve_one_var(name, input, resolved, visiting)?;
511 }
512 Ok(Some(value_to_string(&resolved[name])))
513 })?;
514 *s = new_s;
515 }
516 Value::Array(a) => {
517 for item in a.iter_mut() {
518 resolve_vars_recursive(item, input, resolved, visiting)?;
519 }
520 }
521 Value::Object(m) => {
522 for item in m.values_mut() {
523 resolve_vars_recursive(item, input, resolved, visiting)?;
524 }
525 }
526 _ => {}
527 }
528 Ok(())
529}
530
531fn resolve_vars_only(v: &mut Value, vars: &HashMap<String, Value>) -> CliResult<()> {
534 match v {
535 Value::String(s) => {
536 let new_s = rewrite(s, |body| {
537 let Some(name) = body.strip_prefix("vars.") else {
538 return Ok(None);
539 };
540 let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
541 name: name.to_string(),
542 token: format!("${{{body}}}"),
543 })?;
544 Ok(Some(value_to_string(val)))
545 })?;
546 *s = new_s;
547 }
548 Value::Array(a) => {
549 for item in a.iter_mut() {
550 resolve_vars_only(item, vars)?;
551 }
552 }
553 Value::Object(m) => {
554 for item in m.values_mut() {
555 resolve_vars_only(item, vars)?;
556 }
557 }
558 _ => {}
559 }
560 Ok(())
561}
562
563fn resolve_value_full(
566 v: &mut Value,
567 vars: &HashMap<String, Value>,
568 templates: &TemplateSnapshot,
569) -> CliResult<()> {
570 match v {
571 Value::String(s) => {
572 let new_s = rewrite(s, |body| {
573 if let Some(name) = body.strip_prefix("vars.") {
574 let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
575 name: name.to_string(),
576 token: format!("${{{body}}}"),
577 })?;
578 return Ok(Some(value_to_string(val)));
579 }
580 if let Some(rest) = body.strip_prefix("sources.") {
581 let mut visiting = Vec::new();
582 return Ok(Some(lookup_template_path(
583 &templates.sources,
584 &templates.sinks,
585 "sources",
586 rest,
587 &mut visiting,
588 )?));
589 }
590 if let Some(rest) = body.strip_prefix("sinks.") {
591 let mut visiting = Vec::new();
592 return Ok(Some(lookup_template_path(
593 &templates.sources,
594 &templates.sinks,
595 "sinks",
596 rest,
597 &mut visiting,
598 )?));
599 }
600 Ok(None)
603 })?;
604 *s = new_s;
605 }
606 Value::Array(a) => {
607 for item in a.iter_mut() {
608 resolve_value_full(item, vars, templates)?;
609 }
610 }
611 Value::Object(m) => {
612 for item in m.values_mut() {
613 resolve_value_full(item, vars, templates)?;
614 }
615 }
616 _ => {}
617 }
618 Ok(())
619}
620
621fn lookup_template_path(
629 sources: &HashMap<String, Value>,
630 sinks: &HashMap<String, Value>,
631 kind: &str,
632 rest: &str,
633 visiting: &mut Vec<String>,
634) -> CliResult<String> {
635 let (name, path) = rest.split_once('.').unwrap_or((rest, ""));
637 let key = format!("{kind}.{name}");
638 if let Some(start) = visiting.iter().position(|k| *k == key) {
639 let chain: Vec<String> = visiting[start..]
640 .iter()
641 .cloned()
642 .chain(std::iter::once(key))
643 .collect();
644 return Err(CliError::InterpolationCycle { chain });
645 }
646 let catalog = if kind == "sources" { sources } else { sinks };
647 let template = catalog
648 .get(name)
649 .ok_or_else(|| CliError::UnknownTemplateRef {
650 token: format!("${{{kind}.{rest}}}"),
651 reason: format!("no {kind} template named '{name}'"),
652 })?;
653 let resolved = resolve_dotted(template, path).ok_or_else(|| CliError::UnknownTemplateRef {
654 token: format!("${{{kind}.{rest}}}"),
655 reason: format!("path '{path}' does not resolve inside {kind} template '{name}'"),
656 })?;
657 let resolved_str = value_to_string(&resolved);
663 visiting.push(key);
664 let out = rewrite(&resolved_str, |body| {
665 if let Some(rest) = body.strip_prefix("sources.") {
666 return Ok(Some(lookup_template_path(
667 sources, sinks, "sources", rest, visiting,
668 )?));
669 }
670 if let Some(rest) = body.strip_prefix("sinks.") {
671 return Ok(Some(lookup_template_path(
672 sources, sinks, "sinks", rest, visiting,
673 )?));
674 }
675 Ok(None)
676 });
677 visiting.pop();
678 out
679}
680
681pub fn resolve_lineage_job_name(template: &str, name: &str, row_id: &str) -> String {
684 template
685 .replace("${name}", name)
686 .replace("${row_id}", row_id)
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692 use serde_json::json;
693 use std::collections::HashMap;
694
695 #[test]
696 fn passes_through_text_with_no_directives() {
697 let out = interpolate("just a string").unwrap();
698 assert_eq!(out, "just a string");
699 }
700
701 #[test]
702 fn substitutes_env_var() {
703 unsafe { std::env::set_var("FAUCET_TEST_VAR", "hello") };
704 let out = interpolate("token=${env:FAUCET_TEST_VAR}").unwrap();
705 assert_eq!(out, "token=hello");
706 unsafe { std::env::remove_var("FAUCET_TEST_VAR") };
707 }
708
709 #[test]
710 fn missing_env_var_is_an_error() {
711 unsafe { std::env::remove_var("FAUCET_TEST_MISSING") };
712 let err = interpolate("token=${env:FAUCET_TEST_MISSING}").unwrap_err();
713 match err {
714 CliError::MissingEnvVar { var, .. } => assert_eq!(var, "FAUCET_TEST_MISSING"),
715 other => panic!("expected MissingEnvVar, got {other:?}"),
716 }
717 }
718
719 #[test]
720 fn secret_prefix_is_env_alias_for_now() {
721 unsafe { std::env::set_var("FAUCET_SECRET_VAR", "shh") };
722 let out = interpolate("${secret:FAUCET_SECRET_VAR}").unwrap();
723 assert_eq!(out, "shh");
724 unsafe { std::env::remove_var("FAUCET_SECRET_VAR") };
725 }
726
727 #[test]
728 fn resolved_env_and_secret_values_are_registered_for_redaction() {
729 let secret = "super-secret-token-abcdef-1234567890"; unsafe { std::env::set_var("FAUCET_M3_REDACT_TOKEN", secret) };
734 let out = interpolate("Authorization: Bearer ${env:FAUCET_M3_REDACT_TOKEN}").unwrap();
735 assert!(out.contains(secret));
736 let redacted = crate::secrets::registry::redact(&out);
737 assert!(
738 !redacted.contains(secret),
739 "resolved ${{env:}} value must be registered for redaction"
740 );
741 assert!(redacted.contains("***"));
742 unsafe { std::env::remove_var("FAUCET_M3_REDACT_TOKEN") };
743 }
744
745 #[test]
746 fn reads_file_directive_and_trims_trailing_newline() {
747 let dir = tempfile::tempdir().unwrap();
748 let path = dir.path().join("token.txt");
749 std::fs::write(&path, "abcdef\n").unwrap();
750 let raw = format!("token=${{file:{}}}", path.display());
751 let out = interpolate(&raw).unwrap();
752 assert_eq!(out, "token=abcdef");
753 }
754
755 #[test]
756 fn file_directive_rejects_oversized_file() {
757 let dir = tempfile::tempdir().unwrap();
760 let path = dir.path().join("big.bin");
761 let big = vec![b'x'; (MAX_INTERPOLATED_FILE_BYTES + 10) as usize];
762 std::fs::write(&path, &big).unwrap();
763 let raw = format!("${{file:{}}}", path.display());
764 match interpolate(&raw).unwrap_err() {
765 CliError::InterpolatedFileTooLarge { max_bytes, .. } => {
766 assert_eq!(max_bytes, MAX_INTERPOLATED_FILE_BYTES);
767 }
768 other => panic!("expected InterpolatedFileTooLarge, got {other:?}"),
769 }
770 }
771
772 #[test]
773 fn file_directive_reads_file_at_the_limit() {
774 let dir = tempfile::tempdir().unwrap();
776 let path = dir.path().join("ok.bin");
777 std::fs::write(&path, vec![b'a'; MAX_INTERPOLATED_FILE_BYTES as usize]).unwrap();
778 let raw = format!("${{file:{}}}", path.display());
779 assert!(interpolate(&raw).is_ok());
780 }
781
782 #[test]
783 fn load_time_leaves_id_path_tokens_alone() {
784 unsafe { std::env::set_var("FAUCET_T", "v") };
785 let out = interpolate("a=${env:FAUCET_T} b=${users.id}").unwrap();
786 assert_eq!(out, "a=v b=${users.id}");
787 unsafe { std::env::remove_var("FAUCET_T") };
788 }
789
790 #[test]
791 fn load_time_passes_unknown_prefix_through() {
792 let out = interpolate("${weird:thing}").unwrap();
796 assert_eq!(out, "${weird:thing}");
797 }
798
799 #[test]
800 fn classify_colon_is_load_time_dot_is_deferred() {
801 assert!(matches!(
803 classify_directive("env:VAR"),
804 Directive::LoadTime {
805 prefix: "env",
806 body: "VAR"
807 }
808 ));
809 assert!(matches!(
811 classify_directive("env.foo"),
812 Directive::Deferred {
813 id: "env",
814 path: "foo"
815 }
816 ));
817 assert!(matches!(
818 classify_directive("users.addr.city"),
819 Directive::Deferred {
820 id: "users",
821 path: "addr.city"
822 }
823 ));
824 assert!(matches!(
825 classify_directive("row"),
826 Directive::Deferred {
827 id: "row",
828 path: ""
829 }
830 ));
831 }
832
833 #[test]
834 fn iter_directives_finds_tokens_and_skips_escapes() {
835 let toks: Vec<_> = iter_directives("a=${env:V} b=${users.id} c=$${lit}").collect();
836 assert_eq!(toks.len(), 2);
838 assert_eq!(toks[0].0, "${env:V}");
839 assert!(matches!(
840 toks[0].1,
841 Directive::LoadTime { prefix: "env", .. }
842 ));
843 assert_eq!(toks[1].0, "${users.id}");
844 assert!(matches!(toks[1].1, Directive::Deferred { id: "users", .. }));
845 }
846
847 #[test]
848 fn dollar_dollar_brace_is_escaped() {
849 let out = interpolate("path=$${env:VAR}").unwrap();
850 assert_eq!(out, "path=${env:VAR}");
851 }
852
853 #[test]
854 fn unclosed_directive_is_left_literal() {
855 let out = interpolate("hello ${env:NOPE").unwrap();
856 assert_eq!(out, "hello ${env:NOPE");
857 }
858
859 #[test]
860 fn multiple_directives_resolve_in_order() {
861 unsafe { std::env::set_var("FAUCET_A", "one") };
862 unsafe { std::env::set_var("FAUCET_B", "two") };
863 let out = interpolate("${env:FAUCET_A}-${env:FAUCET_B}").unwrap();
864 assert_eq!(out, "one-two");
865 unsafe { std::env::remove_var("FAUCET_A") };
866 unsafe { std::env::remove_var("FAUCET_B") };
867 }
868
869 fn ctx_with(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
872 pairs
873 .iter()
874 .map(|(k, v)| ((*k).into(), v.clone()))
875 .collect()
876 }
877
878 #[test]
879 fn record_resolves_simple_dotted_path() {
880 let ctx = ctx_with(&[("users", json!({"id": 42, "name": "alice"}))]);
881 let out = interpolate_record("/v1/users/${users.id}", &ctx).unwrap();
882 assert_eq!(out, "/v1/users/42");
883 }
884
885 #[test]
886 fn record_resolves_nested_dotted_path() {
887 let ctx = ctx_with(&[(
888 "users",
889 json!({"id": 1, "addr": {"city": "NYC", "zip": "10001"}}),
890 )]);
891 let out = interpolate_record("/${users.addr.city}/${users.addr.zip}", &ctx).unwrap();
892 assert_eq!(out, "/NYC/10001");
893 }
894
895 #[test]
896 fn record_resolves_array_index() {
897 let ctx = ctx_with(&[("users", json!({"tags": ["a", "b", "c"]}))]);
898 let out = interpolate_record("first=${users.tags.0}", &ctx).unwrap();
899 assert_eq!(out, "first=a");
900 }
901
902 #[test]
903 fn record_renders_numbers_and_booleans_as_strings() {
904 let ctx = ctx_with(&[("users", json!({"id": 7, "active": true}))]);
905 let out = interpolate_record("id=${users.id} active=${users.active}", &ctx).unwrap();
906 assert_eq!(out, "id=7 active=true");
907 }
908
909 #[test]
910 fn record_unknown_id_errors() {
911 let ctx = ctx_with(&[("users", json!({"id": 1}))]);
912 let err = interpolate_record("${nobody.x}", &ctx).unwrap_err();
913 assert!(matches!(err, CliError::UnknownInterpolationId { .. }));
914 }
915
916 #[test]
917 fn record_missing_field_errors() {
918 let ctx = ctx_with(&[("users", json!({"id": 1}))]);
919 let err = interpolate_record("${users.missing}", &ctx).unwrap_err();
920 match err {
921 CliError::MissingRecordField { id, path } => {
922 assert_eq!(id, "users");
923 assert_eq!(path, "missing");
924 }
925 other => panic!("expected MissingRecordField, got {other:?}"),
926 }
927 }
928
929 #[test]
930 fn record_leaves_load_time_directives_alone() {
931 let ctx = HashMap::new();
932 let out = interpolate_record("a=${env:NOPE} b=${file:./x}", &ctx).unwrap();
933 assert_eq!(out, "a=${env:NOPE} b=${file:./x}");
934 }
935
936 use crate::config::{PipelineConfig, parse_with_extension};
939 use crate::interpolate::resolve_config_refs;
940
941 fn load(yaml: &str) -> PipelineConfig {
942 let mut cfg = parse_with_extension(yaml, "yaml").unwrap();
943 resolve_config_refs(&mut cfg).unwrap();
944 cfg
945 }
946
947 #[test]
948 fn resolves_vars_in_source_config() {
949 let cfg = load(
950 r#"
951version: 1
952vars:
953 base: https://api.example.com
954pipeline:
955 source: { type: rest, config: { base_url: "${vars.base}" } }
956 sink: { type: jsonl, config: { path: ./o.jsonl } }
957"#,
958 );
959 assert_eq!(
960 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
961 "https://api.example.com"
962 );
963 }
964
965 #[test]
966 fn resolves_vars_referencing_other_vars() {
967 let cfg = load(
968 r#"
969version: 1
970vars:
971 base: https://api.example.com
972 users_url: "${vars.base}/v1/users"
973pipeline:
974 source: { type: rest, config: { url: "${vars.users_url}" } }
975 sink: { type: jsonl, config: { path: ./o.jsonl } }
976"#,
977 );
978 assert_eq!(
979 cfg.pipeline.source.as_ref().unwrap().config["url"],
980 "https://api.example.com/v1/users"
981 );
982 }
983
984 #[test]
985 fn resolves_template_ref_from_matrix_row() {
986 let cfg = load(
987 r#"
988version: 1
989pipeline:
990 sources:
991 users_api:
992 type: rest
993 config: { base_url: https://api.example.com }
994 sinks:
995 archive: { type: jsonl, config: { path: ./out.jsonl } }
996matrix:
997 - id: load_users
998 source:
999 ref: users_api
1000 config: { audit_url: "${sources.users_api.config.base_url}/audit" }
1001"#,
1002 );
1003 let row_src = cfg.matrix[0].source.as_ref().unwrap();
1004 assert_eq!(
1005 row_src.config.as_ref().unwrap()["audit_url"],
1006 "https://api.example.com/audit"
1007 );
1008 }
1009
1010 #[test]
1011 fn detects_vars_cycle() {
1012 let yaml = r#"
1013version: 1
1014vars:
1015 a: "${vars.b}"
1016 b: "${vars.c}"
1017 c: "${vars.a}"
1018pipeline:
1019 source: { type: rest, config: {} }
1020 sink: { type: jsonl, config: { path: ./o.jsonl } }
1021"#;
1022 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1024 match err {
1025 CliError::InterpolationCycle { chain } => {
1026 assert_eq!(chain.len(), 4, "chain: {chain:?}");
1029 assert_eq!(chain.first(), chain.last(), "chain: {chain:?}");
1030 let mut sorted_interior: Vec<_> = chain[..3].to_vec();
1032 sorted_interior.sort();
1033 assert_eq!(sorted_interior, vec!["vars.a", "vars.b", "vars.c"]);
1034 }
1035 other => panic!("expected InterpolationCycle, got {other:?}"),
1036 }
1037 }
1038
1039 #[test]
1040 fn resolves_cross_template_reference() {
1041 let cfg = load(
1043 r#"
1044version: 1
1045pipeline:
1046 sources:
1047 a: { type: rest, config: { host: api.example.com } }
1048 b: { type: rest, config: { host: "${sources.a.config.host}" } }
1049 sinks:
1050 out: { type: jsonl, config: { path: ./o.jsonl } }
1051"#,
1052 );
1053 assert_eq!(cfg.pipeline.sources["b"].config["host"], "api.example.com");
1054 }
1055
1056 #[test]
1057 fn resolves_chained_cross_template_reference() {
1058 let cfg = parse_with_extension(
1064 r#"
1065version: 1
1066pipeline:
1067 sources:
1068 a: { type: rest, config: { host: "${sources.b.config.host}" } }
1069 b: { type: rest, config: { host: "${sources.c.config.host}" } }
1070 c: { type: rest, config: { host: db.example.com } }
1071 sinks:
1072 out: { type: jsonl, config: { path: ./o.jsonl } }
1073"#,
1074 "yaml",
1075 )
1076 .unwrap();
1077 assert_eq!(cfg.pipeline.sources["a"].config["host"], "db.example.com");
1078 assert_eq!(cfg.pipeline.sources["b"].config["host"], "db.example.com");
1079 }
1080
1081 #[test]
1082 fn resolves_cross_template_reference_across_kinds() {
1083 let cfg = load(
1085 r#"
1086version: 1
1087pipeline:
1088 sources:
1089 api: { type: rest, config: { host: api.example.com } }
1090 sinks:
1091 mirror: { type: http, config: { url: "${sources.api.config.host}" } }
1092"#,
1093 );
1094 assert_eq!(
1095 cfg.pipeline.sinks["mirror"].config["url"],
1096 "api.example.com"
1097 );
1098 }
1099
1100 #[test]
1101 fn detects_cross_template_cycle() {
1102 let yaml = r#"
1105version: 1
1106pipeline:
1107 sources:
1108 a: { type: rest, config: { host: "${sources.b.config.host}" } }
1109 b: { type: rest, config: { host: "${sources.a.config.host}" } }
1110 sinks:
1111 out: { type: jsonl, config: { path: ./o.jsonl } }
1112"#;
1113 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1114 match err {
1115 CliError::InterpolationCycle { chain } => {
1116 assert!(chain.first() == chain.last(), "chain: {chain:?}");
1117 assert!(
1118 chain.iter().any(|c| c == "sources.a")
1119 && chain.iter().any(|c| c == "sources.b"),
1120 "chain must name both templates: {chain:?}"
1121 );
1122 }
1123 other => panic!("expected InterpolationCycle, got {other:?}"),
1124 }
1125 }
1126
1127 #[test]
1128 fn unknown_template_path_errors() {
1129 let yaml = r#"
1131version: 1
1132pipeline:
1133 sources:
1134 a: { type: rest, config: { host: x } }
1135 source: { type: rest, config: { x: "${sources.a.config.missing_field}" } }
1136 sink: { type: jsonl, config: { path: ./o.jsonl } }
1137"#;
1138 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1140 match err {
1141 CliError::UnknownTemplateRef { reason, .. } => {
1142 assert!(reason.contains("missing_field"));
1143 }
1144 other => panic!("expected UnknownTemplateRef, got {other:?}"),
1145 }
1146 }
1147
1148 #[test]
1149 fn unknown_var_errors() {
1150 let yaml = r#"
1151version: 1
1152pipeline:
1153 source: { type: rest, config: { url: "${vars.nope}" } }
1154 sink: { type: jsonl, config: { path: ./o.jsonl } }
1155"#;
1156 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1158 match err {
1159 CliError::UnknownVarsRef { name, .. } => assert_eq!(name, "nope"),
1160 other => panic!("expected UnknownVarsRef, got {other:?}"),
1161 }
1162 }
1163
1164 #[test]
1165 fn unknown_template_ref_errors() {
1166 let yaml = r#"
1167version: 1
1168pipeline:
1169 source: { type: rest, config: { x: "${sources.nope.config.foo}" } }
1170 sink: { type: jsonl, config: { path: ./o.jsonl } }
1171"#;
1172 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1174 match err {
1175 CliError::UnknownTemplateRef { reason, .. } => {
1176 assert!(reason.to_ascii_lowercase().contains("nope"));
1177 }
1178 other => panic!("expected UnknownTemplateRef, got {other:?}"),
1179 }
1180 }
1181
1182 #[test]
1183 fn leaves_row_id_tokens_for_runtime() {
1184 let cfg = load(
1185 r#"
1186version: 1
1187pipeline:
1188 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1189 sink: { type: jsonl, config: { path: ./o.jsonl } }
1190"#,
1191 );
1192 assert_eq!(
1194 cfg.pipeline.source.as_ref().unwrap().config["path"],
1195 "/v1/users/${users.id}/posts"
1196 );
1197 }
1198
1199 #[test]
1200 fn resolves_vars_inside_auth_catalog() {
1201 let cfg = load(
1204 r#"
1205version: 1
1206vars:
1207 idp_token: topsecret
1208auth:
1209 idp: { type: static, config: { token: "Bearer ${vars.idp_token}" } }
1210pipeline:
1211 source: { type: rest, config: { base_url: https://x } }
1212 sink: { type: jsonl, config: { path: ./o.jsonl } }
1213"#,
1214 );
1215 assert_eq!(
1216 cfg.auth.as_ref().unwrap()["idp"]["config"]["token"],
1217 "Bearer topsecret"
1218 );
1219 }
1220
1221 fn fixed_clock() -> chrono::DateTime<chrono::FixedOffset> {
1224 use chrono::TimeZone;
1225 chrono::FixedOffset::east_opt(0)
1227 .unwrap()
1228 .with_ymd_and_hms(2026, 3, 8, 14, 5, 9)
1229 .unwrap()
1230 }
1231
1232 #[test]
1233 fn now_named_tokens_render() {
1234 let c = fixed_clock();
1235 assert_eq!(resolve_now("${now.date}", c).unwrap(), "2026-03-08");
1236 assert_eq!(resolve_now("${now.year}", c).unwrap(), "2026");
1237 assert_eq!(resolve_now("${now.month}", c).unwrap(), "03");
1238 assert_eq!(resolve_now("${now.day}", c).unwrap(), "08");
1239 assert_eq!(resolve_now("${now.hour}", c).unwrap(), "14");
1240 assert_eq!(resolve_now("${now.minute}", c).unwrap(), "05");
1241 assert_eq!(resolve_now("${now.second}", c).unwrap(), "09");
1242 assert_eq!(
1243 resolve_now("${now.unix}", c).unwrap(),
1244 c.timestamp().to_string()
1245 );
1246 assert!(
1247 resolve_now("${now.iso}", c)
1248 .unwrap()
1249 .starts_with("2026-03-08T14:05:09")
1250 );
1251 assert_eq!(
1252 resolve_now("${now.datetime}", c).unwrap(),
1253 resolve_now("${now.iso}", c).unwrap()
1254 );
1255 }
1256
1257 #[test]
1258 fn now_in_a_path_template() {
1259 let c = fixed_clock();
1260 assert_eq!(
1261 resolve_now("s3://bucket/dt=${now.date}/part.jsonl", c).unwrap(),
1262 "s3://bucket/dt=2026-03-08/part.jsonl"
1263 );
1264 }
1265
1266 #[test]
1267 fn now_strftime_renders_and_rejects_bad_format() {
1268 let c = fixed_clock();
1269 assert_eq!(
1270 resolve_now("${now.strftime.%Y/%m/%d}", c).unwrap(),
1271 "2026/03/08"
1272 );
1273 let err = resolve_now("${now.strftime.%Q}", c).unwrap_err();
1276 assert!(err.to_string().contains("strftime"));
1277 }
1278
1279 #[test]
1280 fn now_unknown_token_errors() {
1281 let c = fixed_clock();
1282 let err = resolve_now("${now.bogus}", c).unwrap_err();
1283 assert!(err.to_string().contains("now.bogus"));
1284 }
1285
1286 #[test]
1287 fn now_leaves_other_tokens_verbatim() {
1288 let c = fixed_clock();
1289 assert_eq!(
1291 resolve_now("${env:VAR}/${users.id}/${now.date}", c).unwrap(),
1292 "${env:VAR}/${users.id}/2026-03-08"
1293 );
1294 }
1295
1296 #[test]
1297 fn resolves_lineage_job_name_tokens() {
1298 assert_eq!(
1299 resolve_lineage_job_name("${name}::${row_id}", "orders", "users"),
1300 "orders::users"
1301 );
1302 assert_eq!(
1303 resolve_lineage_job_name("static", "orders", "users"),
1304 "static"
1305 );
1306 }
1307}