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_value(value: &mut Value) -> CliResult<()> {
75 match value {
76 Value::String(s) => {
77 *s = interpolate(s)?;
78 }
79 Value::Array(items) => {
80 for item in items {
81 interpolate_value(item)?;
82 }
83 }
84 Value::Object(map) => {
85 let entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
88 for (key, mut val) in entries {
89 interpolate_value(&mut val)?;
90 let resolved_key = interpolate(&key)?;
91 map.insert(resolved_key, val);
92 }
93 }
94 _ => {}
95 }
96 Ok(())
97}
98
99pub fn interpolate_record(input: &str, ctx: &HashMap<String, Value>) -> CliResult<String> {
105 rewrite(input, |body| match classify_directive(body) {
106 Directive::LoadTime { .. } => Ok(None),
107 Directive::Deferred { id, path } => {
108 let record = ctx
109 .get(id)
110 .ok_or_else(|| CliError::UnknownInterpolationId {
111 id: id.to_owned(),
112 token: format!("${{{body}}}"),
113 })?;
114 let resolved =
115 resolve_dotted(record, path).ok_or_else(|| CliError::MissingRecordField {
116 id: id.to_owned(),
117 path: path.to_owned(),
118 })?;
119 Ok(Some(value_to_string(&resolved)))
120 }
121 })
122}
123
124pub fn resolve_now(input: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
129 rewrite(input, |body| {
130 if let Directive::Deferred { id: "now", path } = classify_directive(body) {
131 return Ok(Some(now_token(path, clock)?));
132 }
133 Ok(None)
134 })
135}
136
137fn now_token(path: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
139 if let Some(fmt) = path.strip_prefix("strftime.") {
141 use chrono::format::{Item, StrftimeItems};
144 let items: Vec<Item> = StrftimeItems::new(fmt).collect();
145 if items.iter().any(|i| matches!(i, Item::Error)) {
146 return Err(CliError::Config(format!(
147 "invalid strftime format in `${{now.strftime.{fmt}}}`"
148 )));
149 }
150 return Ok(clock.format_with_items(items.iter()).to_string());
151 }
152 let rendered = match path {
153 "date" => clock.format("%Y-%m-%d").to_string(),
154 "datetime" | "iso" => clock.to_rfc3339(),
155 "year" => clock.format("%Y").to_string(),
156 "month" => clock.format("%m").to_string(),
157 "day" => clock.format("%d").to_string(),
158 "hour" => clock.format("%H").to_string(),
159 "minute" => clock.format("%M").to_string(),
160 "second" => clock.format("%S").to_string(),
161 "unix" => clock.timestamp().to_string(),
162 other => {
163 return Err(CliError::Config(format!(
164 "unknown `${{now.{other}}}` token — valid: date, datetime, iso, year, month, day, hour, minute, second, unix, strftime.<fmt>"
165 )));
166 }
167 };
168 Ok(rendered)
169}
170
171pub(crate) fn rewrite<F>(input: &str, mut resolve: F) -> CliResult<String>
174where
175 F: FnMut(&str) -> CliResult<Option<String>>,
176{
177 let mut out = String::with_capacity(input.len());
178 let bytes = input.as_bytes();
179 let mut i = 0;
180 while i < bytes.len() {
181 if bytes[i] == b'$' && i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
183 out.push('$');
184 i += 2;
185 continue;
186 }
187 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
188 let start = i + 2;
189 let Some(rel_end) = input[start..].find('}') else {
190 out.push_str(&input[i..]);
192 break;
193 };
194 let end = start + rel_end;
195 let body = &input[start..end];
196 match resolve(body)? {
197 Some(s) => out.push_str(&s),
198 None => out.push_str(&input[i..=end]),
199 }
200 i = end + 1;
201 continue;
202 }
203 let ch = input[i..].chars().next().unwrap();
204 out.push(ch);
205 i += ch.len_utf8();
206 }
207 Ok(out)
208}
209
210pub enum Directive<'a> {
221 LoadTime { prefix: &'a str, body: &'a str },
223 Deferred { id: &'a str, path: &'a str },
226}
227
228pub fn classify_directive(body: &str) -> Directive<'_> {
229 match body.split_once(':') {
230 Some((prefix, rest)) => Directive::LoadTime { prefix, body: rest },
231 None => {
232 let (id, path) = body.split_once('.').unwrap_or((body, ""));
233 Directive::Deferred { id, path }
234 }
235 }
236}
237
238pub fn iter_directives(s: &str) -> impl Iterator<Item = (&str, Directive<'_>)> {
244 let bytes = s.as_bytes();
245 let mut i = 0;
246 std::iter::from_fn(move || {
247 while i < bytes.len() {
248 if bytes[i] == b'$'
250 && i + 2 < bytes.len()
251 && bytes[i + 1] == b'$'
252 && bytes[i + 2] == b'{'
253 {
254 i += 2;
255 continue;
256 }
257 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
258 let start = i;
259 let body_start = i + 2;
260 let rel_end = s[body_start..].find('}')?;
261 let end = body_start + rel_end;
262 i = end + 1;
263 let body = &s[body_start..end];
264 return Some((&s[start..=end], classify_directive(body)));
265 }
266 i += 1;
267 }
268 None
269 })
270}
271
272const MAX_INTERPOLATED_FILE_BYTES: u64 = 1024 * 1024; fn read_file_trimmed(path_str: &str) -> CliResult<String> {
280 use std::io::Read as _;
281 let path = PathBuf::from(path_str);
282 let file = std::fs::File::open(&path).map_err(|source| CliError::ReadInterpolatedFile {
283 path: path.clone(),
284 source,
285 })?;
286 let mut buf = Vec::new();
289 file.take(MAX_INTERPOLATED_FILE_BYTES + 1)
290 .read_to_end(&mut buf)
291 .map_err(|source| CliError::ReadInterpolatedFile {
292 path: path.clone(),
293 source,
294 })?;
295 if buf.len() as u64 > MAX_INTERPOLATED_FILE_BYTES {
296 return Err(CliError::InterpolatedFileTooLarge {
297 path,
298 max_bytes: MAX_INTERPOLATED_FILE_BYTES,
299 });
300 }
301 Ok(String::from_utf8_lossy(&buf).trim_end().to_owned())
302}
303
304fn resolve_dotted(root: &Value, path: &str) -> Option<Value> {
307 if path.is_empty() {
308 return Some(root.clone());
309 }
310 let mut cur = root;
311 for segment in path.split('.') {
312 cur = match cur {
313 Value::Object(map) => map.get(segment)?,
314 Value::Array(arr) => {
315 let idx: usize = segment.parse().ok()?;
316 arr.get(idx)?
317 }
318 _ => return None,
319 };
320 }
321 Some(cur.clone())
322}
323
324fn value_to_string(v: &Value) -> String {
328 match v {
329 Value::String(s) => s.clone(),
330 other => other.to_string(),
331 }
332}
333
334pub fn resolve_config_refs(cfg: &mut crate::config::PipelineConfig) -> CliResult<()> {
354 if let Some(vars) = cfg.vars.clone() {
356 let resolved = resolve_vars_block(&vars)?;
357 cfg.vars = Some(resolved);
358 }
359
360 let empty_vars: HashMap<String, Value> = HashMap::new();
361 let vars_ref: &HashMap<String, Value> = cfg.vars.as_ref().unwrap_or(&empty_vars);
362
363 for (_name, spec) in cfg.pipeline.sources.iter_mut() {
366 resolve_vars_only(&mut spec.config, vars_ref)?;
367 }
368 for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
369 resolve_vars_only(&mut spec.config, vars_ref)?;
370 }
371 if let Some(spec) = cfg.pipeline.source.as_mut() {
372 resolve_vars_only(&mut spec.config, vars_ref)?;
373 }
374 if let Some(spec) = cfg.pipeline.sink.as_mut() {
375 resolve_vars_only(&mut spec.config, vars_ref)?;
376 }
377
378 let snapshot = TemplateSnapshot::capture(&cfg.pipeline);
380
381 for (_name, spec) in cfg.pipeline.sources.iter_mut() {
384 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
385 }
386 for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
387 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
388 }
389 if let Some(spec) = cfg.pipeline.source.as_mut() {
390 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
391 }
392 if let Some(spec) = cfg.pipeline.sink.as_mut() {
393 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
394 }
395 for t in cfg.pipeline.transforms.iter_mut() {
396 resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
397 }
398 if let Some(s) = cfg.pipeline.state.as_mut() {
399 resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
400 }
401 if let Some(d) = cfg.pipeline.dlq.as_mut() {
402 resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
403 }
404 if let Some(auth) = cfg.auth.as_mut() {
407 for (_name, spec) in auth.iter_mut() {
408 resolve_value_full(spec, vars_ref, &snapshot)?;
409 }
410 }
411 if let Some(r) = cfg.replication.as_mut() {
415 resolve_value_full(&mut r.snapshot.source.config, vars_ref, &snapshot)?;
416 }
417 for (i, row) in cfg.matrix.iter_mut().enumerate() {
418 let _row_owner = row.id.clone().unwrap_or_else(|| format!("row-{i}"));
419 if let Some(p) = row.source.as_mut()
420 && let Some(c) = p.config.as_mut()
421 {
422 resolve_value_full(c, vars_ref, &snapshot)?;
423 }
424 if let Some(p) = row.sink.as_mut()
425 && let Some(c) = p.config.as_mut()
426 {
427 resolve_value_full(c, vars_ref, &snapshot)?;
428 }
429 if let Some(ts) = row.transforms.as_mut() {
430 for t in ts.iter_mut() {
431 resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
432 }
433 }
434 if let Some(s) = row.state.as_mut() {
435 resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
436 }
437 if let Some(Some(d)) = row.dlq.as_mut() {
438 resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
439 }
440 }
441 Ok(())
442}
443
444struct TemplateSnapshot {
447 sources: HashMap<String, Value>,
448 sinks: HashMap<String, Value>,
449}
450
451impl TemplateSnapshot {
452 fn capture(spec: &crate::config::PipelineSpec) -> Self {
453 let mut sources: HashMap<String, Value> = spec
454 .sources
455 .iter()
456 .map(|(k, v)| {
457 (
458 k.clone(),
459 serde_json::to_value(v)
460 .expect("ConnectorSpec derives Serialize and cannot fail"),
461 )
462 })
463 .collect();
464 if let Some(s) = &spec.source {
465 sources.entry("default".into()).or_insert_with(|| {
466 serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
467 });
468 }
469 let mut sinks: HashMap<String, Value> = spec
470 .sinks
471 .iter()
472 .map(|(k, v)| {
473 (
474 k.clone(),
475 serde_json::to_value(v)
476 .expect("ConnectorSpec derives Serialize and cannot fail"),
477 )
478 })
479 .collect();
480 if let Some(s) = &spec.sink {
481 sinks.entry("default".into()).or_insert_with(|| {
482 serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
483 });
484 }
485 Self { sources, sinks }
486 }
487}
488
489fn resolve_vars_block(input: &HashMap<String, Value>) -> CliResult<HashMap<String, Value>> {
492 let mut resolved: HashMap<String, Value> = HashMap::new();
493 let mut visiting: Vec<String> = Vec::new();
494 for key in input.keys() {
495 resolve_one_var(key, input, &mut resolved, &mut visiting)?;
496 }
497 Ok(resolved)
498}
499
500fn resolve_one_var(
501 key: &str,
502 input: &HashMap<String, Value>,
503 resolved: &mut HashMap<String, Value>,
504 visiting: &mut Vec<String>,
505) -> CliResult<()> {
506 if resolved.contains_key(key) {
507 return Ok(());
508 }
509 if let Some(start) = visiting.iter().position(|k| k == key) {
510 let chain: Vec<String> = visiting[start..]
514 .iter()
515 .map(|k| format!("vars.{k}"))
516 .chain(std::iter::once(format!("vars.{key}")))
517 .collect();
518 return Err(CliError::InterpolationCycle { chain });
519 }
520 visiting.push(key.to_string());
521 let mut value = input
522 .get(key)
523 .expect("key was taken from input map")
524 .clone();
525 resolve_vars_recursive(&mut value, input, resolved, visiting)?;
526 visiting.pop();
527 resolved.insert(key.to_string(), value);
528 Ok(())
529}
530
531fn resolve_vars_recursive(
534 v: &mut Value,
535 input: &HashMap<String, Value>,
536 resolved: &mut HashMap<String, Value>,
537 visiting: &mut Vec<String>,
538) -> CliResult<()> {
539 match v {
540 Value::String(s) => {
541 let new_s = rewrite(s, |body| {
542 let Some(name) = body.strip_prefix("vars.") else {
543 return Ok(None); };
545 if !resolved.contains_key(name) {
546 if !input.contains_key(name) {
547 return Err(CliError::UnknownVarsRef {
548 name: name.to_string(),
549 token: format!("${{{body}}}"),
550 });
551 }
552 resolve_one_var(name, input, resolved, visiting)?;
553 }
554 Ok(Some(value_to_string(&resolved[name])))
555 })?;
556 *s = new_s;
557 }
558 Value::Array(a) => {
559 for item in a.iter_mut() {
560 resolve_vars_recursive(item, input, resolved, visiting)?;
561 }
562 }
563 Value::Object(m) => {
564 for item in m.values_mut() {
565 resolve_vars_recursive(item, input, resolved, visiting)?;
566 }
567 }
568 _ => {}
569 }
570 Ok(())
571}
572
573fn resolve_vars_only(v: &mut Value, vars: &HashMap<String, Value>) -> CliResult<()> {
576 match v {
577 Value::String(s) => {
578 let new_s = rewrite(s, |body| {
579 let Some(name) = body.strip_prefix("vars.") else {
580 return Ok(None);
581 };
582 let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
583 name: name.to_string(),
584 token: format!("${{{body}}}"),
585 })?;
586 Ok(Some(value_to_string(val)))
587 })?;
588 *s = new_s;
589 }
590 Value::Array(a) => {
591 for item in a.iter_mut() {
592 resolve_vars_only(item, vars)?;
593 }
594 }
595 Value::Object(m) => {
596 for item in m.values_mut() {
597 resolve_vars_only(item, vars)?;
598 }
599 }
600 _ => {}
601 }
602 Ok(())
603}
604
605fn resolve_value_full(
608 v: &mut Value,
609 vars: &HashMap<String, Value>,
610 templates: &TemplateSnapshot,
611) -> CliResult<()> {
612 match v {
613 Value::String(s) => {
614 let new_s = rewrite(s, |body| {
615 if let Some(name) = body.strip_prefix("vars.") {
616 let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
617 name: name.to_string(),
618 token: format!("${{{body}}}"),
619 })?;
620 return Ok(Some(value_to_string(val)));
621 }
622 if let Some(rest) = body.strip_prefix("sources.") {
623 let mut visiting = Vec::new();
624 return Ok(Some(lookup_template_path(
625 &templates.sources,
626 &templates.sinks,
627 "sources",
628 rest,
629 &mut visiting,
630 )?));
631 }
632 if let Some(rest) = body.strip_prefix("sinks.") {
633 let mut visiting = Vec::new();
634 return Ok(Some(lookup_template_path(
635 &templates.sources,
636 &templates.sinks,
637 "sinks",
638 rest,
639 &mut visiting,
640 )?));
641 }
642 Ok(None)
645 })?;
646 *s = new_s;
647 }
648 Value::Array(a) => {
649 for item in a.iter_mut() {
650 resolve_value_full(item, vars, templates)?;
651 }
652 }
653 Value::Object(m) => {
654 for item in m.values_mut() {
655 resolve_value_full(item, vars, templates)?;
656 }
657 }
658 _ => {}
659 }
660 Ok(())
661}
662
663fn lookup_template_path(
671 sources: &HashMap<String, Value>,
672 sinks: &HashMap<String, Value>,
673 kind: &str,
674 rest: &str,
675 visiting: &mut Vec<String>,
676) -> CliResult<String> {
677 let (name, path) = rest.split_once('.').unwrap_or((rest, ""));
679 let key = format!("{kind}.{name}");
680 if let Some(start) = visiting.iter().position(|k| *k == key) {
681 let chain: Vec<String> = visiting[start..]
682 .iter()
683 .cloned()
684 .chain(std::iter::once(key))
685 .collect();
686 return Err(CliError::InterpolationCycle { chain });
687 }
688 let catalog = if kind == "sources" { sources } else { sinks };
689 let template = catalog
690 .get(name)
691 .ok_or_else(|| CliError::UnknownTemplateRef {
692 token: format!("${{{kind}.{rest}}}"),
693 reason: format!("no {kind} template named '{name}'"),
694 })?;
695 let resolved = resolve_dotted(template, path).ok_or_else(|| CliError::UnknownTemplateRef {
696 token: format!("${{{kind}.{rest}}}"),
697 reason: format!("path '{path}' does not resolve inside {kind} template '{name}'"),
698 })?;
699 let resolved_str = value_to_string(&resolved);
705 visiting.push(key);
706 let out = rewrite(&resolved_str, |body| {
707 if let Some(rest) = body.strip_prefix("sources.") {
708 return Ok(Some(lookup_template_path(
709 sources, sinks, "sources", rest, visiting,
710 )?));
711 }
712 if let Some(rest) = body.strip_prefix("sinks.") {
713 return Ok(Some(lookup_template_path(
714 sources, sinks, "sinks", rest, visiting,
715 )?));
716 }
717 Ok(None)
718 });
719 visiting.pop();
720 out
721}
722
723pub fn resolve_lineage_job_name(template: &str, name: &str, row_id: &str) -> String {
726 template
727 .replace("${name}", name)
728 .replace("${row_id}", row_id)
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use serde_json::json;
735 use std::collections::HashMap;
736
737 #[test]
738 fn passes_through_text_with_no_directives() {
739 let out = interpolate("just a string").unwrap();
740 assert_eq!(out, "just a string");
741 }
742
743 #[test]
744 fn substitutes_env_var() {
745 unsafe { std::env::set_var("FAUCET_TEST_VAR", "hello") };
746 let out = interpolate("token=${env:FAUCET_TEST_VAR}").unwrap();
747 assert_eq!(out, "token=hello");
748 unsafe { std::env::remove_var("FAUCET_TEST_VAR") };
749 }
750
751 #[test]
752 fn missing_env_var_is_an_error() {
753 unsafe { std::env::remove_var("FAUCET_TEST_MISSING") };
754 let err = interpolate("token=${env:FAUCET_TEST_MISSING}").unwrap_err();
755 match err {
756 CliError::MissingEnvVar { var, .. } => assert_eq!(var, "FAUCET_TEST_MISSING"),
757 other => panic!("expected MissingEnvVar, got {other:?}"),
758 }
759 }
760
761 #[test]
762 fn interpolate_value_resolves_scalars_in_strings_keys_and_arrays() {
763 unsafe { std::env::set_var("FAUCET_F43_TOKEN", "sekret") };
764 let mut v = json!({
765 "${env:FAUCET_F43_KEY}": "kv",
766 "auth": {"token": "${env:FAUCET_F43_TOKEN}"},
767 "list": ["${env:FAUCET_F43_TOKEN}", 42, true],
768 "deferred": "${users.id}",
769 "num": 5,
770 });
771 unsafe { std::env::set_var("FAUCET_F43_KEY", "ckey") };
772 interpolate_value(&mut v).unwrap();
773 assert_eq!(v["auth"]["token"], "sekret");
774 assert_eq!(v["list"][0], "sekret");
775 assert_eq!(v["list"][1], 42); assert_eq!(v["list"][2], true);
777 assert_eq!(v["deferred"], "${users.id}"); assert_eq!(v["num"], 5);
779 assert_eq!(v["ckey"], "kv"); unsafe { std::env::remove_var("FAUCET_F43_TOKEN") };
781 unsafe { std::env::remove_var("FAUCET_F43_KEY") };
782 }
783
784 #[test]
785 fn interpolate_value_keeps_resolved_value_as_a_single_scalar() {
786 unsafe { std::env::set_var("FAUCET_F43_INJECT", "real\ninjected_key: pwned\nmore: x") };
790 let mut v = json!({ "name": "${env:FAUCET_F43_INJECT}" });
791 interpolate_value(&mut v).unwrap();
792 assert_eq!(v["name"], "real\ninjected_key: pwned\nmore: x");
793 assert_eq!(v.as_object().unwrap().len(), 1);
795 assert!(v.get("injected_key").is_none());
796 unsafe { std::env::remove_var("FAUCET_F43_INJECT") };
797 }
798
799 #[test]
800 fn secret_prefix_is_env_alias_for_now() {
801 unsafe { std::env::set_var("FAUCET_SECRET_VAR", "shh") };
802 let out = interpolate("${secret:FAUCET_SECRET_VAR}").unwrap();
803 assert_eq!(out, "shh");
804 unsafe { std::env::remove_var("FAUCET_SECRET_VAR") };
805 }
806
807 #[test]
808 fn resolved_env_and_secret_values_are_registered_for_redaction() {
809 let secret = "super-secret-token-abcdef-1234567890"; unsafe { std::env::set_var("FAUCET_M3_REDACT_TOKEN", secret) };
814 let out = interpolate("Authorization: Bearer ${env:FAUCET_M3_REDACT_TOKEN}").unwrap();
815 assert!(out.contains(secret));
816 let redacted = crate::secrets::registry::redact(&out);
817 assert!(
818 !redacted.contains(secret),
819 "resolved ${{env:}} value must be registered for redaction"
820 );
821 assert!(redacted.contains("***"));
822 unsafe { std::env::remove_var("FAUCET_M3_REDACT_TOKEN") };
823 }
824
825 #[test]
826 fn reads_file_directive_and_trims_trailing_newline() {
827 let dir = tempfile::tempdir().unwrap();
828 let path = dir.path().join("token.txt");
829 std::fs::write(&path, "abcdef\n").unwrap();
830 let raw = format!("token=${{file:{}}}", path.display());
831 let out = interpolate(&raw).unwrap();
832 assert_eq!(out, "token=abcdef");
833 }
834
835 #[test]
836 fn file_directive_rejects_oversized_file() {
837 let dir = tempfile::tempdir().unwrap();
840 let path = dir.path().join("big.bin");
841 let big = vec![b'x'; (MAX_INTERPOLATED_FILE_BYTES + 10) as usize];
842 std::fs::write(&path, &big).unwrap();
843 let raw = format!("${{file:{}}}", path.display());
844 match interpolate(&raw).unwrap_err() {
845 CliError::InterpolatedFileTooLarge { max_bytes, .. } => {
846 assert_eq!(max_bytes, MAX_INTERPOLATED_FILE_BYTES);
847 }
848 other => panic!("expected InterpolatedFileTooLarge, got {other:?}"),
849 }
850 }
851
852 #[test]
853 fn file_directive_reads_file_at_the_limit() {
854 let dir = tempfile::tempdir().unwrap();
856 let path = dir.path().join("ok.bin");
857 std::fs::write(&path, vec![b'a'; MAX_INTERPOLATED_FILE_BYTES as usize]).unwrap();
858 let raw = format!("${{file:{}}}", path.display());
859 assert!(interpolate(&raw).is_ok());
860 }
861
862 #[test]
863 fn load_time_leaves_id_path_tokens_alone() {
864 unsafe { std::env::set_var("FAUCET_T", "v") };
865 let out = interpolate("a=${env:FAUCET_T} b=${users.id}").unwrap();
866 assert_eq!(out, "a=v b=${users.id}");
867 unsafe { std::env::remove_var("FAUCET_T") };
868 }
869
870 #[test]
871 fn load_time_passes_unknown_prefix_through() {
872 let out = interpolate("${weird:thing}").unwrap();
876 assert_eq!(out, "${weird:thing}");
877 }
878
879 #[test]
880 fn classify_colon_is_load_time_dot_is_deferred() {
881 assert!(matches!(
883 classify_directive("env:VAR"),
884 Directive::LoadTime {
885 prefix: "env",
886 body: "VAR"
887 }
888 ));
889 assert!(matches!(
891 classify_directive("env.foo"),
892 Directive::Deferred {
893 id: "env",
894 path: "foo"
895 }
896 ));
897 assert!(matches!(
898 classify_directive("users.addr.city"),
899 Directive::Deferred {
900 id: "users",
901 path: "addr.city"
902 }
903 ));
904 assert!(matches!(
905 classify_directive("row"),
906 Directive::Deferred {
907 id: "row",
908 path: ""
909 }
910 ));
911 }
912
913 #[test]
914 fn iter_directives_finds_tokens_and_skips_escapes() {
915 let toks: Vec<_> = iter_directives("a=${env:V} b=${users.id} c=$${lit}").collect();
916 assert_eq!(toks.len(), 2);
918 assert_eq!(toks[0].0, "${env:V}");
919 assert!(matches!(
920 toks[0].1,
921 Directive::LoadTime { prefix: "env", .. }
922 ));
923 assert_eq!(toks[1].0, "${users.id}");
924 assert!(matches!(toks[1].1, Directive::Deferred { id: "users", .. }));
925 }
926
927 #[test]
928 fn dollar_dollar_brace_is_escaped() {
929 let out = interpolate("path=$${env:VAR}").unwrap();
930 assert_eq!(out, "path=${env:VAR}");
931 }
932
933 #[test]
934 fn unclosed_directive_is_left_literal() {
935 let out = interpolate("hello ${env:NOPE").unwrap();
936 assert_eq!(out, "hello ${env:NOPE");
937 }
938
939 #[test]
940 fn multiple_directives_resolve_in_order() {
941 unsafe { std::env::set_var("FAUCET_A", "one") };
942 unsafe { std::env::set_var("FAUCET_B", "two") };
943 let out = interpolate("${env:FAUCET_A}-${env:FAUCET_B}").unwrap();
944 assert_eq!(out, "one-two");
945 unsafe { std::env::remove_var("FAUCET_A") };
946 unsafe { std::env::remove_var("FAUCET_B") };
947 }
948
949 fn ctx_with(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
952 pairs
953 .iter()
954 .map(|(k, v)| ((*k).into(), v.clone()))
955 .collect()
956 }
957
958 #[test]
959 fn record_resolves_simple_dotted_path() {
960 let ctx = ctx_with(&[("users", json!({"id": 42, "name": "alice"}))]);
961 let out = interpolate_record("/v1/users/${users.id}", &ctx).unwrap();
962 assert_eq!(out, "/v1/users/42");
963 }
964
965 #[test]
966 fn record_resolves_nested_dotted_path() {
967 let ctx = ctx_with(&[(
968 "users",
969 json!({"id": 1, "addr": {"city": "NYC", "zip": "10001"}}),
970 )]);
971 let out = interpolate_record("/${users.addr.city}/${users.addr.zip}", &ctx).unwrap();
972 assert_eq!(out, "/NYC/10001");
973 }
974
975 #[test]
976 fn record_resolves_array_index() {
977 let ctx = ctx_with(&[("users", json!({"tags": ["a", "b", "c"]}))]);
978 let out = interpolate_record("first=${users.tags.0}", &ctx).unwrap();
979 assert_eq!(out, "first=a");
980 }
981
982 #[test]
983 fn record_renders_numbers_and_booleans_as_strings() {
984 let ctx = ctx_with(&[("users", json!({"id": 7, "active": true}))]);
985 let out = interpolate_record("id=${users.id} active=${users.active}", &ctx).unwrap();
986 assert_eq!(out, "id=7 active=true");
987 }
988
989 #[test]
990 fn record_unknown_id_errors() {
991 let ctx = ctx_with(&[("users", json!({"id": 1}))]);
992 let err = interpolate_record("${nobody.x}", &ctx).unwrap_err();
993 assert!(matches!(err, CliError::UnknownInterpolationId { .. }));
994 }
995
996 #[test]
997 fn record_missing_field_errors() {
998 let ctx = ctx_with(&[("users", json!({"id": 1}))]);
999 let err = interpolate_record("${users.missing}", &ctx).unwrap_err();
1000 match err {
1001 CliError::MissingRecordField { id, path } => {
1002 assert_eq!(id, "users");
1003 assert_eq!(path, "missing");
1004 }
1005 other => panic!("expected MissingRecordField, got {other:?}"),
1006 }
1007 }
1008
1009 #[test]
1010 fn record_leaves_load_time_directives_alone() {
1011 let ctx = HashMap::new();
1012 let out = interpolate_record("a=${env:NOPE} b=${file:./x}", &ctx).unwrap();
1013 assert_eq!(out, "a=${env:NOPE} b=${file:./x}");
1014 }
1015
1016 use crate::config::{PipelineConfig, parse_with_extension};
1019 use crate::interpolate::resolve_config_refs;
1020
1021 fn load(yaml: &str) -> PipelineConfig {
1022 let mut cfg = parse_with_extension(yaml, "yaml").unwrap();
1023 resolve_config_refs(&mut cfg).unwrap();
1024 cfg
1025 }
1026
1027 #[test]
1028 fn resolves_vars_in_source_config() {
1029 let cfg = load(
1030 r#"
1031version: 1
1032vars:
1033 base: https://api.example.com
1034pipeline:
1035 source: { type: rest, config: { base_url: "${vars.base}" } }
1036 sink: { type: jsonl, config: { path: ./o.jsonl } }
1037"#,
1038 );
1039 assert_eq!(
1040 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1041 "https://api.example.com"
1042 );
1043 }
1044
1045 #[test]
1046 fn resolves_vars_in_replication_snapshot_source_config() {
1047 let cfg = load(
1050 r#"
1051version: 1
1052vars:
1053 base: postgres://db/orders
1054pipeline:
1055 source: { type: postgres-cdc, config: {} }
1056 sink: { type: jsonl, config: { path: ./o.jsonl } }
1057replication:
1058 mode: snapshot_then_cdc
1059 snapshot:
1060 source:
1061 type: postgres
1062 config: { connection_url: "${vars.base}", query: "SELECT 1" }
1063"#,
1064 );
1065 assert_eq!(
1066 cfg.replication.as_ref().unwrap().snapshot.source.config["connection_url"],
1067 "postgres://db/orders"
1068 );
1069 }
1070
1071 #[test]
1072 fn resolves_vars_referencing_other_vars() {
1073 let cfg = load(
1074 r#"
1075version: 1
1076vars:
1077 base: https://api.example.com
1078 users_url: "${vars.base}/v1/users"
1079pipeline:
1080 source: { type: rest, config: { url: "${vars.users_url}" } }
1081 sink: { type: jsonl, config: { path: ./o.jsonl } }
1082"#,
1083 );
1084 assert_eq!(
1085 cfg.pipeline.source.as_ref().unwrap().config["url"],
1086 "https://api.example.com/v1/users"
1087 );
1088 }
1089
1090 #[test]
1091 fn resolves_template_ref_from_matrix_row() {
1092 let cfg = load(
1093 r#"
1094version: 1
1095pipeline:
1096 sources:
1097 users_api:
1098 type: rest
1099 config: { base_url: https://api.example.com }
1100 sinks:
1101 archive: { type: jsonl, config: { path: ./out.jsonl } }
1102matrix:
1103 - id: load_users
1104 source:
1105 ref: users_api
1106 config: { audit_url: "${sources.users_api.config.base_url}/audit" }
1107"#,
1108 );
1109 let row_src = cfg.matrix[0].source.as_ref().unwrap();
1110 assert_eq!(
1111 row_src.config.as_ref().unwrap()["audit_url"],
1112 "https://api.example.com/audit"
1113 );
1114 }
1115
1116 #[test]
1117 fn detects_vars_cycle() {
1118 let yaml = r#"
1119version: 1
1120vars:
1121 a: "${vars.b}"
1122 b: "${vars.c}"
1123 c: "${vars.a}"
1124pipeline:
1125 source: { type: rest, config: {} }
1126 sink: { type: jsonl, config: { path: ./o.jsonl } }
1127"#;
1128 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1130 match err {
1131 CliError::InterpolationCycle { chain } => {
1132 assert_eq!(chain.len(), 4, "chain: {chain:?}");
1135 assert_eq!(chain.first(), chain.last(), "chain: {chain:?}");
1136 let mut sorted_interior: Vec<_> = chain[..3].to_vec();
1138 sorted_interior.sort();
1139 assert_eq!(sorted_interior, vec!["vars.a", "vars.b", "vars.c"]);
1140 }
1141 other => panic!("expected InterpolationCycle, got {other:?}"),
1142 }
1143 }
1144
1145 #[test]
1146 fn resolves_cross_template_reference() {
1147 let cfg = load(
1149 r#"
1150version: 1
1151pipeline:
1152 sources:
1153 a: { type: rest, config: { host: api.example.com } }
1154 b: { type: rest, config: { host: "${sources.a.config.host}" } }
1155 sinks:
1156 out: { type: jsonl, config: { path: ./o.jsonl } }
1157"#,
1158 );
1159 assert_eq!(cfg.pipeline.sources["b"].config["host"], "api.example.com");
1160 }
1161
1162 #[test]
1163 fn resolves_chained_cross_template_reference() {
1164 let cfg = parse_with_extension(
1170 r#"
1171version: 1
1172pipeline:
1173 sources:
1174 a: { type: rest, config: { host: "${sources.b.config.host}" } }
1175 b: { type: rest, config: { host: "${sources.c.config.host}" } }
1176 c: { type: rest, config: { host: db.example.com } }
1177 sinks:
1178 out: { type: jsonl, config: { path: ./o.jsonl } }
1179"#,
1180 "yaml",
1181 )
1182 .unwrap();
1183 assert_eq!(cfg.pipeline.sources["a"].config["host"], "db.example.com");
1184 assert_eq!(cfg.pipeline.sources["b"].config["host"], "db.example.com");
1185 }
1186
1187 #[test]
1188 fn resolves_cross_template_reference_across_kinds() {
1189 let cfg = load(
1191 r#"
1192version: 1
1193pipeline:
1194 sources:
1195 api: { type: rest, config: { host: api.example.com } }
1196 sinks:
1197 mirror: { type: http, config: { url: "${sources.api.config.host}" } }
1198"#,
1199 );
1200 assert_eq!(
1201 cfg.pipeline.sinks["mirror"].config["url"],
1202 "api.example.com"
1203 );
1204 }
1205
1206 #[test]
1207 fn detects_cross_template_cycle() {
1208 let yaml = r#"
1211version: 1
1212pipeline:
1213 sources:
1214 a: { type: rest, config: { host: "${sources.b.config.host}" } }
1215 b: { type: rest, config: { host: "${sources.a.config.host}" } }
1216 sinks:
1217 out: { type: jsonl, config: { path: ./o.jsonl } }
1218"#;
1219 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1220 match err {
1221 CliError::InterpolationCycle { chain } => {
1222 assert!(chain.first() == chain.last(), "chain: {chain:?}");
1223 assert!(
1224 chain.iter().any(|c| c == "sources.a")
1225 && chain.iter().any(|c| c == "sources.b"),
1226 "chain must name both templates: {chain:?}"
1227 );
1228 }
1229 other => panic!("expected InterpolationCycle, got {other:?}"),
1230 }
1231 }
1232
1233 #[test]
1234 fn unknown_template_path_errors() {
1235 let yaml = r#"
1237version: 1
1238pipeline:
1239 sources:
1240 a: { type: rest, config: { host: x } }
1241 source: { type: rest, config: { x: "${sources.a.config.missing_field}" } }
1242 sink: { type: jsonl, config: { path: ./o.jsonl } }
1243"#;
1244 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1246 match err {
1247 CliError::UnknownTemplateRef { reason, .. } => {
1248 assert!(reason.contains("missing_field"));
1249 }
1250 other => panic!("expected UnknownTemplateRef, got {other:?}"),
1251 }
1252 }
1253
1254 #[test]
1255 fn unknown_var_errors() {
1256 let yaml = r#"
1257version: 1
1258pipeline:
1259 source: { type: rest, config: { url: "${vars.nope}" } }
1260 sink: { type: jsonl, config: { path: ./o.jsonl } }
1261"#;
1262 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1264 match err {
1265 CliError::UnknownVarsRef { name, .. } => assert_eq!(name, "nope"),
1266 other => panic!("expected UnknownVarsRef, got {other:?}"),
1267 }
1268 }
1269
1270 #[test]
1271 fn unknown_template_ref_errors() {
1272 let yaml = r#"
1273version: 1
1274pipeline:
1275 source: { type: rest, config: { x: "${sources.nope.config.foo}" } }
1276 sink: { type: jsonl, config: { path: ./o.jsonl } }
1277"#;
1278 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1280 match err {
1281 CliError::UnknownTemplateRef { reason, .. } => {
1282 assert!(reason.to_ascii_lowercase().contains("nope"));
1283 }
1284 other => panic!("expected UnknownTemplateRef, got {other:?}"),
1285 }
1286 }
1287
1288 #[test]
1289 fn leaves_row_id_tokens_for_runtime() {
1290 let cfg = load(
1291 r#"
1292version: 1
1293pipeline:
1294 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1295 sink: { type: jsonl, config: { path: ./o.jsonl } }
1296"#,
1297 );
1298 assert_eq!(
1300 cfg.pipeline.source.as_ref().unwrap().config["path"],
1301 "/v1/users/${users.id}/posts"
1302 );
1303 }
1304
1305 #[test]
1306 fn resolves_vars_inside_auth_catalog() {
1307 let cfg = load(
1310 r#"
1311version: 1
1312vars:
1313 idp_token: topsecret
1314auth:
1315 idp: { type: static, config: { token: "Bearer ${vars.idp_token}" } }
1316pipeline:
1317 source: { type: rest, config: { base_url: https://x } }
1318 sink: { type: jsonl, config: { path: ./o.jsonl } }
1319"#,
1320 );
1321 assert_eq!(
1322 cfg.auth.as_ref().unwrap()["idp"]["config"]["token"],
1323 "Bearer topsecret"
1324 );
1325 }
1326
1327 fn fixed_clock() -> chrono::DateTime<chrono::FixedOffset> {
1330 use chrono::TimeZone;
1331 chrono::FixedOffset::east_opt(0)
1333 .unwrap()
1334 .with_ymd_and_hms(2026, 3, 8, 14, 5, 9)
1335 .unwrap()
1336 }
1337
1338 #[test]
1339 fn now_named_tokens_render() {
1340 let c = fixed_clock();
1341 assert_eq!(resolve_now("${now.date}", c).unwrap(), "2026-03-08");
1342 assert_eq!(resolve_now("${now.year}", c).unwrap(), "2026");
1343 assert_eq!(resolve_now("${now.month}", c).unwrap(), "03");
1344 assert_eq!(resolve_now("${now.day}", c).unwrap(), "08");
1345 assert_eq!(resolve_now("${now.hour}", c).unwrap(), "14");
1346 assert_eq!(resolve_now("${now.minute}", c).unwrap(), "05");
1347 assert_eq!(resolve_now("${now.second}", c).unwrap(), "09");
1348 assert_eq!(
1349 resolve_now("${now.unix}", c).unwrap(),
1350 c.timestamp().to_string()
1351 );
1352 assert!(
1353 resolve_now("${now.iso}", c)
1354 .unwrap()
1355 .starts_with("2026-03-08T14:05:09")
1356 );
1357 assert_eq!(
1358 resolve_now("${now.datetime}", c).unwrap(),
1359 resolve_now("${now.iso}", c).unwrap()
1360 );
1361 }
1362
1363 #[test]
1364 fn now_in_a_path_template() {
1365 let c = fixed_clock();
1366 assert_eq!(
1367 resolve_now("s3://bucket/dt=${now.date}/part.jsonl", c).unwrap(),
1368 "s3://bucket/dt=2026-03-08/part.jsonl"
1369 );
1370 }
1371
1372 #[test]
1373 fn now_strftime_renders_and_rejects_bad_format() {
1374 let c = fixed_clock();
1375 assert_eq!(
1376 resolve_now("${now.strftime.%Y/%m/%d}", c).unwrap(),
1377 "2026/03/08"
1378 );
1379 let err = resolve_now("${now.strftime.%Q}", c).unwrap_err();
1382 assert!(err.to_string().contains("strftime"));
1383 }
1384
1385 #[test]
1386 fn now_unknown_token_errors() {
1387 let c = fixed_clock();
1388 let err = resolve_now("${now.bogus}", c).unwrap_err();
1389 assert!(err.to_string().contains("now.bogus"));
1390 }
1391
1392 #[test]
1393 fn now_leaves_other_tokens_verbatim() {
1394 let c = fixed_clock();
1395 assert_eq!(
1397 resolve_now("${env:VAR}/${users.id}/${now.date}", c).unwrap(),
1398 "${env:VAR}/${users.id}/2026-03-08"
1399 );
1400 }
1401
1402 #[test]
1403 fn resolves_lineage_job_name_tokens() {
1404 assert_eq!(
1405 resolve_lineage_job_name("${name}::${row_id}", "orders", "users"),
1406 "orders::users"
1407 );
1408 assert_eq!(
1409 resolve_lineage_job_name("static", "orders", "users"),
1410 "static"
1411 );
1412 }
1413}