1use crate::error::{CliError, CliResult};
25use chrono::{DateTime, FixedOffset};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::path::PathBuf;
29
30pub type EnvOverlay = HashMap<String, String>;
36
37pub fn interpolate(input: &str) -> CliResult<String> {
41 interpolate_with_env(input, &EnvOverlay::new())
42}
43
44pub fn interpolate_with_env(input: &str, overlay: &EnvOverlay) -> CliResult<String> {
47 rewrite(input, |body| match classify_directive(body) {
48 Directive::LoadTime { prefix, body } => match prefix {
49 "env" | "secret" => {
50 let value = match overlay.get(body) {
51 Some(v) => v.clone(),
52 None => std::env::var(body).map_err(|_| CliError::MissingEnvVar {
53 var: body.to_owned(),
54 location: format!("${{{prefix}:{body}}}"),
55 })?,
56 };
57 crate::secrets::registry::register(&value);
64 Ok(Some(value))
65 }
66 "file" => {
67 let value = read_file_trimmed(body)?;
68 crate::secrets::registry::register(&value);
69 Ok(Some(value))
70 }
71 _ => Ok(None),
74 },
75 Directive::Deferred { .. } => Ok(None),
76 })
77}
78
79pub fn interpolate_value(value: &mut Value) -> CliResult<()> {
91 interpolate_value_with_env(value, &EnvOverlay::new())
92}
93
94pub fn interpolate_value_with_env(value: &mut Value, overlay: &EnvOverlay) -> CliResult<()> {
97 match value {
98 Value::String(s) => {
99 *s = interpolate_with_env(s, overlay)?;
100 }
101 Value::Array(items) => {
102 for item in items {
103 interpolate_value_with_env(item, overlay)?;
104 }
105 }
106 Value::Object(map) => {
107 let entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
110 for (key, mut val) in entries {
111 interpolate_value_with_env(&mut val, overlay)?;
112 let resolved_key = interpolate_with_env(&key, overlay)?;
113 map.insert(resolved_key, val);
114 }
115 }
116 _ => {}
117 }
118 Ok(())
119}
120
121pub fn interpolate_record(input: &str, ctx: &HashMap<String, Value>) -> CliResult<String> {
127 rewrite(input, |body| match classify_directive(body) {
128 Directive::LoadTime { .. } => Ok(None),
129 Directive::Deferred { id, path } => {
130 let record = ctx
131 .get(id)
132 .ok_or_else(|| CliError::UnknownInterpolationId {
133 id: id.to_owned(),
134 token: format!("${{{body}}}"),
135 })?;
136 let resolved =
137 resolve_dotted(record, path).ok_or_else(|| CliError::MissingRecordField {
138 id: id.to_owned(),
139 path: path.to_owned(),
140 })?;
141 Ok(Some(value_to_string_record(&resolved)))
142 }
143 })
144}
145
146pub(crate) fn value_to_string_record(v: &Value) -> String {
154 match v {
155 Value::Array(items) => items
156 .iter()
157 .map(value_to_string)
158 .collect::<Vec<_>>()
159 .join(","),
160 other => value_to_string(other),
161 }
162}
163
164pub fn resolve_now(input: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
169 rewrite(input, |body| {
170 if let Directive::Deferred { id: "now", path } = classify_directive(body) {
171 return Ok(Some(now_token(path, clock)?));
172 }
173 Ok(None)
174 })
175}
176
177fn now_token(path: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
179 if let Some(fmt) = path.strip_prefix("strftime.") {
181 use chrono::format::{Item, StrftimeItems};
184 let items: Vec<Item> = StrftimeItems::new(fmt).collect();
185 if items.iter().any(|i| matches!(i, Item::Error)) {
186 return Err(CliError::Config(format!(
187 "invalid strftime format in `${{now.strftime.{fmt}}}`"
188 )));
189 }
190 return Ok(clock.format_with_items(items.iter()).to_string());
191 }
192 let rendered = match path {
193 "date" => clock.format("%Y-%m-%d").to_string(),
194 "datetime" | "iso" => clock.to_rfc3339(),
195 "year" => clock.format("%Y").to_string(),
196 "month" => clock.format("%m").to_string(),
197 "day" => clock.format("%d").to_string(),
198 "hour" => clock.format("%H").to_string(),
199 "minute" => clock.format("%M").to_string(),
200 "second" => clock.format("%S").to_string(),
201 "unix" => clock.timestamp().to_string(),
202 other => {
203 return Err(CliError::Config(format!(
204 "unknown `${{now.{other}}}` token — valid: date, datetime, iso, year, month, day, hour, minute, second, unix, strftime.<fmt>"
205 )));
206 }
207 };
208 Ok(rendered)
209}
210
211pub(crate) fn rewrite<F>(input: &str, mut resolve: F) -> CliResult<String>
214where
215 F: FnMut(&str) -> CliResult<Option<String>>,
216{
217 let mut out = String::with_capacity(input.len());
218 let bytes = input.as_bytes();
219 let mut i = 0;
220 while i < bytes.len() {
221 if bytes[i] == b'$' && i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
223 out.push('$');
224 i += 2;
225 continue;
226 }
227 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
228 let start = i + 2;
229 let Some(rel_end) = input[start..].find('}') else {
230 out.push_str(&input[i..]);
232 break;
233 };
234 let end = start + rel_end;
235 let body = &input[start..end];
236 match resolve(body)? {
237 Some(s) => out.push_str(&s),
238 None => out.push_str(&input[i..=end]),
239 }
240 i = end + 1;
241 continue;
242 }
243 let ch = input[i..].chars().next().unwrap();
244 out.push(ch);
245 i += ch.len_utf8();
246 }
247 Ok(out)
248}
249
250pub enum Directive<'a> {
261 LoadTime { prefix: &'a str, body: &'a str },
263 Deferred { id: &'a str, path: &'a str },
266}
267
268pub fn classify_directive(body: &str) -> Directive<'_> {
269 match body.split_once(':') {
270 Some((prefix, rest)) => Directive::LoadTime { prefix, body: rest },
271 None => {
272 let (id, path) = body.split_once('.').unwrap_or((body, ""));
273 Directive::Deferred { id, path }
274 }
275 }
276}
277
278pub fn iter_directives(s: &str) -> impl Iterator<Item = (&str, Directive<'_>)> {
284 let bytes = s.as_bytes();
285 let mut i = 0;
286 std::iter::from_fn(move || {
287 while i < bytes.len() {
288 if bytes[i] == b'$'
290 && i + 2 < bytes.len()
291 && bytes[i + 1] == b'$'
292 && bytes[i + 2] == b'{'
293 {
294 i += 2;
295 continue;
296 }
297 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
298 let start = i;
299 let body_start = i + 2;
300 let rel_end = s[body_start..].find('}')?;
301 let end = body_start + rel_end;
302 i = end + 1;
303 let body = &s[body_start..end];
304 return Some((&s[start..=end], classify_directive(body)));
305 }
306 i += 1;
307 }
308 None
309 })
310}
311
312const MAX_INTERPOLATED_FILE_BYTES: u64 = 1024 * 1024; fn read_file_trimmed(path_str: &str) -> CliResult<String> {
320 use std::io::Read as _;
321 let path = PathBuf::from(path_str);
322 let file = std::fs::File::open(&path).map_err(|source| CliError::ReadInterpolatedFile {
323 path: path.clone(),
324 source,
325 })?;
326 let mut buf = Vec::new();
329 file.take(MAX_INTERPOLATED_FILE_BYTES + 1)
330 .read_to_end(&mut buf)
331 .map_err(|source| CliError::ReadInterpolatedFile {
332 path: path.clone(),
333 source,
334 })?;
335 if buf.len() as u64 > MAX_INTERPOLATED_FILE_BYTES {
336 return Err(CliError::InterpolatedFileTooLarge {
337 path,
338 max_bytes: MAX_INTERPOLATED_FILE_BYTES,
339 });
340 }
341 Ok(String::from_utf8_lossy(&buf).trim_end().to_owned())
342}
343
344fn resolve_dotted(root: &Value, path: &str) -> Option<Value> {
347 if path.is_empty() {
348 return Some(root.clone());
349 }
350 let mut cur = root;
351 for segment in path.split('.') {
352 cur = match cur {
353 Value::Object(map) => map.get(segment)?,
354 Value::Array(arr) => {
355 let idx: usize = segment.parse().ok()?;
356 arr.get(idx)?
357 }
358 _ => return None,
359 };
360 }
361 Some(cur.clone())
362}
363
364pub(crate) fn value_to_string(v: &Value) -> String {
368 match v {
369 Value::String(s) => s.clone(),
370 other => other.to_string(),
371 }
372}
373
374pub fn resolve_config_refs(cfg: &mut crate::config::PipelineConfig) -> CliResult<()> {
394 if let Some(vars) = cfg.vars.clone() {
396 let resolved = resolve_vars_block(&vars)?;
397 cfg.vars = Some(resolved);
398 }
399
400 let empty_vars: HashMap<String, Value> = HashMap::new();
401 let vars_ref: &HashMap<String, Value> = cfg.vars.as_ref().unwrap_or(&empty_vars);
402
403 for (_name, spec) in cfg.pipeline.sources.iter_mut() {
406 resolve_vars_only(&mut spec.config, vars_ref)?;
407 }
408 for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
409 resolve_vars_only(&mut spec.config, vars_ref)?;
410 }
411 if let Some(spec) = cfg.pipeline.source.as_mut() {
412 resolve_vars_only(&mut spec.config, vars_ref)?;
413 }
414 if let Some(spec) = cfg.pipeline.sink.as_mut() {
415 resolve_vars_only(&mut spec.config, vars_ref)?;
416 }
417
418 let snapshot = TemplateSnapshot::capture(&cfg.pipeline);
420
421 for (_name, spec) in cfg.pipeline.sources.iter_mut() {
424 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
425 }
426 for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
427 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
428 }
429 if let Some(spec) = cfg.pipeline.source.as_mut() {
430 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
431 }
432 if let Some(spec) = cfg.pipeline.sink.as_mut() {
433 resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
434 }
435 for t in cfg.pipeline.transforms.iter_mut() {
436 resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
437 }
438 if let Some(s) = cfg.pipeline.state.as_mut() {
439 resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
440 }
441 if let Some(d) = cfg.pipeline.dlq.as_mut() {
442 resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
443 }
444 if let Some(auth) = cfg.auth.as_mut() {
447 for (_name, spec) in auth.iter_mut() {
448 resolve_value_full(spec, vars_ref, &snapshot)?;
449 }
450 }
451 if let Some(r) = cfg.replication.as_mut() {
455 resolve_value_full(&mut r.snapshot.source.config, vars_ref, &snapshot)?;
456 }
457 for (i, row) in cfg.matrix.iter_mut().enumerate() {
458 let _row_owner = row.id.clone().unwrap_or_else(|| format!("row-{i}"));
459 if let Some(p) = row.source.as_mut()
460 && let Some(c) = p.config.as_mut()
461 {
462 resolve_value_full(c, vars_ref, &snapshot)?;
463 }
464 if let Some(p) = row.sink.as_mut()
465 && let Some(c) = p.config.as_mut()
466 {
467 resolve_value_full(c, vars_ref, &snapshot)?;
468 }
469 if let Some(ts) = row.transforms.as_mut() {
470 for t in ts.iter_mut() {
471 resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
472 }
473 }
474 if let Some(s) = row.state.as_mut() {
475 resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
476 }
477 if let Some(Some(d)) = row.dlq.as_mut() {
478 resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
479 }
480 }
481 Ok(())
482}
483
484struct TemplateSnapshot {
487 sources: HashMap<String, Value>,
488 sinks: HashMap<String, Value>,
489}
490
491impl TemplateSnapshot {
492 fn capture(spec: &crate::config::PipelineSpec) -> Self {
493 let mut sources: HashMap<String, Value> = spec
494 .sources
495 .iter()
496 .map(|(k, v)| {
497 (
498 k.clone(),
499 serde_json::to_value(v)
500 .expect("ConnectorSpec derives Serialize and cannot fail"),
501 )
502 })
503 .collect();
504 if let Some(s) = &spec.source {
505 sources.entry("default".into()).or_insert_with(|| {
506 serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
507 });
508 }
509 let mut sinks: HashMap<String, Value> = spec
510 .sinks
511 .iter()
512 .map(|(k, v)| {
513 (
514 k.clone(),
515 serde_json::to_value(v)
516 .expect("ConnectorSpec derives Serialize and cannot fail"),
517 )
518 })
519 .collect();
520 if let Some(s) = &spec.sink {
521 sinks.entry("default".into()).or_insert_with(|| {
522 serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
523 });
524 }
525 Self { sources, sinks }
526 }
527}
528
529fn resolve_vars_block(input: &HashMap<String, Value>) -> CliResult<HashMap<String, Value>> {
532 let mut resolved: HashMap<String, Value> = HashMap::new();
533 let mut visiting: Vec<String> = Vec::new();
534 for key in input.keys() {
535 resolve_one_var(key, input, &mut resolved, &mut visiting)?;
536 }
537 Ok(resolved)
538}
539
540fn resolve_one_var(
541 key: &str,
542 input: &HashMap<String, Value>,
543 resolved: &mut HashMap<String, Value>,
544 visiting: &mut Vec<String>,
545) -> CliResult<()> {
546 if resolved.contains_key(key) {
547 return Ok(());
548 }
549 if let Some(start) = visiting.iter().position(|k| k == key) {
550 let chain: Vec<String> = visiting[start..]
554 .iter()
555 .map(|k| format!("vars.{k}"))
556 .chain(std::iter::once(format!("vars.{key}")))
557 .collect();
558 return Err(CliError::InterpolationCycle { chain });
559 }
560 visiting.push(key.to_string());
561 let mut value = input
562 .get(key)
563 .expect("key was taken from input map")
564 .clone();
565 resolve_vars_recursive(&mut value, input, resolved, visiting)?;
566 visiting.pop();
567 resolved.insert(key.to_string(), value);
568 Ok(())
569}
570
571fn resolve_vars_recursive(
574 v: &mut Value,
575 input: &HashMap<String, Value>,
576 resolved: &mut HashMap<String, Value>,
577 visiting: &mut Vec<String>,
578) -> CliResult<()> {
579 match v {
580 Value::String(s) => {
581 let new_s = rewrite(s, |body| {
582 let Some(name) = body.strip_prefix("vars.") else {
583 return Ok(None); };
585 if !resolved.contains_key(name) {
586 if !input.contains_key(name) {
587 return Err(CliError::UnknownVarsRef {
588 name: name.to_string(),
589 token: format!("${{{body}}}"),
590 });
591 }
592 resolve_one_var(name, input, resolved, visiting)?;
593 }
594 Ok(Some(value_to_string(&resolved[name])))
595 })?;
596 *s = new_s;
597 }
598 Value::Array(a) => {
599 for item in a.iter_mut() {
600 resolve_vars_recursive(item, input, resolved, visiting)?;
601 }
602 }
603 Value::Object(m) => {
604 for item in m.values_mut() {
605 resolve_vars_recursive(item, input, resolved, visiting)?;
606 }
607 }
608 _ => {}
609 }
610 Ok(())
611}
612
613fn resolve_vars_only(v: &mut Value, vars: &HashMap<String, Value>) -> CliResult<()> {
616 match v {
617 Value::String(s) => {
618 let new_s = rewrite(s, |body| {
619 let Some(name) = body.strip_prefix("vars.") else {
620 return Ok(None);
621 };
622 let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
623 name: name.to_string(),
624 token: format!("${{{body}}}"),
625 })?;
626 Ok(Some(value_to_string(val)))
627 })?;
628 *s = new_s;
629 }
630 Value::Array(a) => {
631 for item in a.iter_mut() {
632 resolve_vars_only(item, vars)?;
633 }
634 }
635 Value::Object(m) => {
636 for item in m.values_mut() {
637 resolve_vars_only(item, vars)?;
638 }
639 }
640 _ => {}
641 }
642 Ok(())
643}
644
645fn resolve_value_full(
648 v: &mut Value,
649 vars: &HashMap<String, Value>,
650 templates: &TemplateSnapshot,
651) -> CliResult<()> {
652 match v {
653 Value::String(s) => {
654 let new_s = rewrite(s, |body| {
655 if let Some(name) = body.strip_prefix("vars.") {
656 let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
657 name: name.to_string(),
658 token: format!("${{{body}}}"),
659 })?;
660 return Ok(Some(value_to_string(val)));
661 }
662 if let Some(rest) = body.strip_prefix("sources.") {
663 let mut visiting = Vec::new();
664 return Ok(Some(lookup_template_path(
665 &templates.sources,
666 &templates.sinks,
667 "sources",
668 rest,
669 &mut visiting,
670 )?));
671 }
672 if let Some(rest) = body.strip_prefix("sinks.") {
673 let mut visiting = Vec::new();
674 return Ok(Some(lookup_template_path(
675 &templates.sources,
676 &templates.sinks,
677 "sinks",
678 rest,
679 &mut visiting,
680 )?));
681 }
682 Ok(None)
685 })?;
686 *s = new_s;
687 }
688 Value::Array(a) => {
689 for item in a.iter_mut() {
690 resolve_value_full(item, vars, templates)?;
691 }
692 }
693 Value::Object(m) => {
694 for item in m.values_mut() {
695 resolve_value_full(item, vars, templates)?;
696 }
697 }
698 _ => {}
699 }
700 Ok(())
701}
702
703fn lookup_template_path(
711 sources: &HashMap<String, Value>,
712 sinks: &HashMap<String, Value>,
713 kind: &str,
714 rest: &str,
715 visiting: &mut Vec<String>,
716) -> CliResult<String> {
717 let (name, path) = rest.split_once('.').unwrap_or((rest, ""));
719 let key = format!("{kind}.{name}");
720 if let Some(start) = visiting.iter().position(|k| *k == key) {
721 let chain: Vec<String> = visiting[start..]
722 .iter()
723 .cloned()
724 .chain(std::iter::once(key))
725 .collect();
726 return Err(CliError::InterpolationCycle { chain });
727 }
728 let catalog = if kind == "sources" { sources } else { sinks };
729 let template = catalog
730 .get(name)
731 .ok_or_else(|| CliError::UnknownTemplateRef {
732 token: format!("${{{kind}.{rest}}}"),
733 reason: format!("no {kind} template named '{name}'"),
734 })?;
735 let resolved = resolve_dotted(template, path).ok_or_else(|| CliError::UnknownTemplateRef {
736 token: format!("${{{kind}.{rest}}}"),
737 reason: format!("path '{path}' does not resolve inside {kind} template '{name}'"),
738 })?;
739 let resolved_str = value_to_string(&resolved);
745 visiting.push(key);
746 let out = rewrite(&resolved_str, |body| {
747 if let Some(rest) = body.strip_prefix("sources.") {
748 return Ok(Some(lookup_template_path(
749 sources, sinks, "sources", rest, visiting,
750 )?));
751 }
752 if let Some(rest) = body.strip_prefix("sinks.") {
753 return Ok(Some(lookup_template_path(
754 sources, sinks, "sinks", rest, visiting,
755 )?));
756 }
757 Ok(None)
758 });
759 visiting.pop();
760 out
761}
762
763pub fn resolve_lineage_job_name(template: &str, name: &str, row_id: &str) -> String {
766 template
767 .replace("${name}", name)
768 .replace("${row_id}", row_id)
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774 use serde_json::json;
775 use std::collections::HashMap;
776
777 #[test]
778 fn passes_through_text_with_no_directives() {
779 let out = interpolate("just a string").unwrap();
780 assert_eq!(out, "just a string");
781 }
782
783 #[test]
784 fn interpolate_record_comma_joins_collected_lists() {
785 let ctx: HashMap<String, Value> = [(
788 "props".to_string(),
789 json!({"name": ["amount", "stage", "name"]}),
790 )]
791 .into();
792 let out = interpolate_record("${props.name}", &ctx).unwrap();
793 assert_eq!(out, "amount,stage,name");
794 let out2 = interpolate_record("f=${props.name}&x=1", &ctx).unwrap();
796 assert_eq!(out2, "f=amount,stage,name&x=1");
797 }
798
799 #[test]
800 fn interpolate_record_scalar_unchanged() {
801 let ctx: HashMap<String, Value> = [("types".to_string(), json!({"name": "deal"}))].into();
802 assert_eq!(interpolate_record("${types.name}", &ctx).unwrap(), "deal");
803 }
804
805 #[test]
806 fn substitutes_env_var() {
807 unsafe { std::env::set_var("FAUCET_TEST_VAR", "hello") };
808 let out = interpolate("token=${env:FAUCET_TEST_VAR}").unwrap();
809 assert_eq!(out, "token=hello");
810 unsafe { std::env::remove_var("FAUCET_TEST_VAR") };
811 }
812
813 #[test]
814 fn missing_env_var_is_an_error() {
815 unsafe { std::env::remove_var("FAUCET_TEST_MISSING") };
816 let err = interpolate("token=${env:FAUCET_TEST_MISSING}").unwrap_err();
817 match err {
818 CliError::MissingEnvVar { var, .. } => assert_eq!(var, "FAUCET_TEST_MISSING"),
819 other => panic!("expected MissingEnvVar, got {other:?}"),
820 }
821 }
822
823 #[test]
824 fn interpolate_value_resolves_scalars_in_strings_keys_and_arrays() {
825 unsafe { std::env::set_var("FAUCET_F43_TOKEN", "sekret") };
826 let mut v = json!({
827 "${env:FAUCET_F43_KEY}": "kv",
828 "auth": {"token": "${env:FAUCET_F43_TOKEN}"},
829 "list": ["${env:FAUCET_F43_TOKEN}", 42, true],
830 "deferred": "${users.id}",
831 "num": 5,
832 });
833 unsafe { std::env::set_var("FAUCET_F43_KEY", "ckey") };
834 interpolate_value(&mut v).unwrap();
835 assert_eq!(v["auth"]["token"], "sekret");
836 assert_eq!(v["list"][0], "sekret");
837 assert_eq!(v["list"][1], 42); assert_eq!(v["list"][2], true);
839 assert_eq!(v["deferred"], "${users.id}"); assert_eq!(v["num"], 5);
841 assert_eq!(v["ckey"], "kv"); unsafe { std::env::remove_var("FAUCET_F43_TOKEN") };
843 unsafe { std::env::remove_var("FAUCET_F43_KEY") };
844 }
845
846 #[test]
847 fn interpolate_value_keeps_resolved_value_as_a_single_scalar() {
848 unsafe { std::env::set_var("FAUCET_F43_INJECT", "real\ninjected_key: pwned\nmore: x") };
852 let mut v = json!({ "name": "${env:FAUCET_F43_INJECT}" });
853 interpolate_value(&mut v).unwrap();
854 assert_eq!(v["name"], "real\ninjected_key: pwned\nmore: x");
855 assert_eq!(v.as_object().unwrap().len(), 1);
857 assert!(v.get("injected_key").is_none());
858 unsafe { std::env::remove_var("FAUCET_F43_INJECT") };
859 }
860
861 #[test]
862 fn secret_prefix_is_env_alias_for_now() {
863 unsafe { std::env::set_var("FAUCET_SECRET_VAR", "shh") };
864 let out = interpolate("${secret:FAUCET_SECRET_VAR}").unwrap();
865 assert_eq!(out, "shh");
866 unsafe { std::env::remove_var("FAUCET_SECRET_VAR") };
867 }
868
869 #[test]
870 fn resolved_env_and_secret_values_are_registered_for_redaction() {
871 let secret = "super-secret-token-abcdef-1234567890"; unsafe { std::env::set_var("FAUCET_M3_REDACT_TOKEN", secret) };
876 let out = interpolate("Authorization: Bearer ${env:FAUCET_M3_REDACT_TOKEN}").unwrap();
877 assert!(out.contains(secret));
878 let redacted = crate::secrets::registry::redact(&out);
879 assert!(
880 !redacted.contains(secret),
881 "resolved ${{env:}} value must be registered for redaction"
882 );
883 assert!(redacted.contains("***"));
884 unsafe { std::env::remove_var("FAUCET_M3_REDACT_TOKEN") };
885 }
886
887 #[test]
888 fn reads_file_directive_and_trims_trailing_newline() {
889 let dir = tempfile::tempdir().unwrap();
890 let path = dir.path().join("token.txt");
891 std::fs::write(&path, "abcdef\n").unwrap();
892 let raw = format!("token=${{file:{}}}", path.display());
893 let out = interpolate(&raw).unwrap();
894 assert_eq!(out, "token=abcdef");
895 }
896
897 #[test]
898 fn file_directive_rejects_oversized_file() {
899 let dir = tempfile::tempdir().unwrap();
902 let path = dir.path().join("big.bin");
903 let big = vec![b'x'; (MAX_INTERPOLATED_FILE_BYTES + 10) as usize];
904 std::fs::write(&path, &big).unwrap();
905 let raw = format!("${{file:{}}}", path.display());
906 match interpolate(&raw).unwrap_err() {
907 CliError::InterpolatedFileTooLarge { max_bytes, .. } => {
908 assert_eq!(max_bytes, MAX_INTERPOLATED_FILE_BYTES);
909 }
910 other => panic!("expected InterpolatedFileTooLarge, got {other:?}"),
911 }
912 }
913
914 #[test]
915 fn file_directive_reads_file_at_the_limit() {
916 let dir = tempfile::tempdir().unwrap();
918 let path = dir.path().join("ok.bin");
919 std::fs::write(&path, vec![b'a'; MAX_INTERPOLATED_FILE_BYTES as usize]).unwrap();
920 let raw = format!("${{file:{}}}", path.display());
921 assert!(interpolate(&raw).is_ok());
922 }
923
924 #[test]
925 fn load_time_leaves_id_path_tokens_alone() {
926 unsafe { std::env::set_var("FAUCET_T", "v") };
927 let out = interpolate("a=${env:FAUCET_T} b=${users.id}").unwrap();
928 assert_eq!(out, "a=v b=${users.id}");
929 unsafe { std::env::remove_var("FAUCET_T") };
930 }
931
932 #[test]
933 fn load_time_passes_unknown_prefix_through() {
934 let out = interpolate("${weird:thing}").unwrap();
938 assert_eq!(out, "${weird:thing}");
939 }
940
941 #[test]
942 fn classify_colon_is_load_time_dot_is_deferred() {
943 assert!(matches!(
945 classify_directive("env:VAR"),
946 Directive::LoadTime {
947 prefix: "env",
948 body: "VAR"
949 }
950 ));
951 assert!(matches!(
953 classify_directive("env.foo"),
954 Directive::Deferred {
955 id: "env",
956 path: "foo"
957 }
958 ));
959 assert!(matches!(
960 classify_directive("users.addr.city"),
961 Directive::Deferred {
962 id: "users",
963 path: "addr.city"
964 }
965 ));
966 assert!(matches!(
967 classify_directive("row"),
968 Directive::Deferred {
969 id: "row",
970 path: ""
971 }
972 ));
973 }
974
975 #[test]
976 fn iter_directives_finds_tokens_and_skips_escapes() {
977 let toks: Vec<_> = iter_directives("a=${env:V} b=${users.id} c=$${lit}").collect();
978 assert_eq!(toks.len(), 2);
980 assert_eq!(toks[0].0, "${env:V}");
981 assert!(matches!(
982 toks[0].1,
983 Directive::LoadTime { prefix: "env", .. }
984 ));
985 assert_eq!(toks[1].0, "${users.id}");
986 assert!(matches!(toks[1].1, Directive::Deferred { id: "users", .. }));
987 }
988
989 #[test]
990 fn dollar_dollar_brace_is_escaped() {
991 let out = interpolate("path=$${env:VAR}").unwrap();
992 assert_eq!(out, "path=${env:VAR}");
993 }
994
995 #[test]
996 fn unclosed_directive_is_left_literal() {
997 let out = interpolate("hello ${env:NOPE").unwrap();
998 assert_eq!(out, "hello ${env:NOPE");
999 }
1000
1001 #[test]
1002 fn multiple_directives_resolve_in_order() {
1003 unsafe { std::env::set_var("FAUCET_A", "one") };
1004 unsafe { std::env::set_var("FAUCET_B", "two") };
1005 let out = interpolate("${env:FAUCET_A}-${env:FAUCET_B}").unwrap();
1006 assert_eq!(out, "one-two");
1007 unsafe { std::env::remove_var("FAUCET_A") };
1008 unsafe { std::env::remove_var("FAUCET_B") };
1009 }
1010
1011 fn ctx_with(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
1014 pairs
1015 .iter()
1016 .map(|(k, v)| ((*k).into(), v.clone()))
1017 .collect()
1018 }
1019
1020 #[test]
1021 fn record_resolves_simple_dotted_path() {
1022 let ctx = ctx_with(&[("users", json!({"id": 42, "name": "alice"}))]);
1023 let out = interpolate_record("/v1/users/${users.id}", &ctx).unwrap();
1024 assert_eq!(out, "/v1/users/42");
1025 }
1026
1027 #[test]
1028 fn record_resolves_nested_dotted_path() {
1029 let ctx = ctx_with(&[(
1030 "users",
1031 json!({"id": 1, "addr": {"city": "NYC", "zip": "10001"}}),
1032 )]);
1033 let out = interpolate_record("/${users.addr.city}/${users.addr.zip}", &ctx).unwrap();
1034 assert_eq!(out, "/NYC/10001");
1035 }
1036
1037 #[test]
1038 fn record_resolves_array_index() {
1039 let ctx = ctx_with(&[("users", json!({"tags": ["a", "b", "c"]}))]);
1040 let out = interpolate_record("first=${users.tags.0}", &ctx).unwrap();
1041 assert_eq!(out, "first=a");
1042 }
1043
1044 #[test]
1045 fn record_renders_numbers_and_booleans_as_strings() {
1046 let ctx = ctx_with(&[("users", json!({"id": 7, "active": true}))]);
1047 let out = interpolate_record("id=${users.id} active=${users.active}", &ctx).unwrap();
1048 assert_eq!(out, "id=7 active=true");
1049 }
1050
1051 #[test]
1052 fn record_unknown_id_errors() {
1053 let ctx = ctx_with(&[("users", json!({"id": 1}))]);
1054 let err = interpolate_record("${nobody.x}", &ctx).unwrap_err();
1055 assert!(matches!(err, CliError::UnknownInterpolationId { .. }));
1056 }
1057
1058 #[test]
1059 fn record_missing_field_errors() {
1060 let ctx = ctx_with(&[("users", json!({"id": 1}))]);
1061 let err = interpolate_record("${users.missing}", &ctx).unwrap_err();
1062 match err {
1063 CliError::MissingRecordField { id, path } => {
1064 assert_eq!(id, "users");
1065 assert_eq!(path, "missing");
1066 }
1067 other => panic!("expected MissingRecordField, got {other:?}"),
1068 }
1069 }
1070
1071 #[test]
1072 fn record_leaves_load_time_directives_alone() {
1073 let ctx = HashMap::new();
1074 let out = interpolate_record("a=${env:NOPE} b=${file:./x}", &ctx).unwrap();
1075 assert_eq!(out, "a=${env:NOPE} b=${file:./x}");
1076 }
1077
1078 use crate::config::{PipelineConfig, parse_with_extension};
1081 use crate::interpolate::resolve_config_refs;
1082
1083 fn load(yaml: &str) -> PipelineConfig {
1084 let mut cfg = parse_with_extension(yaml, "yaml").unwrap();
1085 resolve_config_refs(&mut cfg).unwrap();
1086 cfg
1087 }
1088
1089 #[test]
1090 fn resolves_vars_in_source_config() {
1091 let cfg = load(
1092 r#"
1093version: 1
1094vars:
1095 base: https://api.example.com
1096pipeline:
1097 source: { type: rest, config: { base_url: "${vars.base}" } }
1098 sink: { type: jsonl, config: { path: ./o.jsonl } }
1099"#,
1100 );
1101 assert_eq!(
1102 cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1103 "https://api.example.com"
1104 );
1105 }
1106
1107 #[test]
1108 fn resolves_vars_in_replication_snapshot_source_config() {
1109 let cfg = load(
1112 r#"
1113version: 1
1114vars:
1115 base: postgres://db/orders
1116pipeline:
1117 source: { type: postgres-cdc, config: {} }
1118 sink: { type: jsonl, config: { path: ./o.jsonl } }
1119replication:
1120 mode: snapshot_then_cdc
1121 snapshot:
1122 source:
1123 type: postgres
1124 config: { connection_url: "${vars.base}", query: "SELECT 1" }
1125"#,
1126 );
1127 assert_eq!(
1128 cfg.replication.as_ref().unwrap().snapshot.source.config["connection_url"],
1129 "postgres://db/orders"
1130 );
1131 }
1132
1133 #[test]
1134 fn resolves_vars_referencing_other_vars() {
1135 let cfg = load(
1136 r#"
1137version: 1
1138vars:
1139 base: https://api.example.com
1140 users_url: "${vars.base}/v1/users"
1141pipeline:
1142 source: { type: rest, config: { url: "${vars.users_url}" } }
1143 sink: { type: jsonl, config: { path: ./o.jsonl } }
1144"#,
1145 );
1146 assert_eq!(
1147 cfg.pipeline.source.as_ref().unwrap().config["url"],
1148 "https://api.example.com/v1/users"
1149 );
1150 }
1151
1152 #[test]
1153 fn resolves_template_ref_from_matrix_row() {
1154 let cfg = load(
1155 r#"
1156version: 1
1157pipeline:
1158 sources:
1159 users_api:
1160 type: rest
1161 config: { base_url: https://api.example.com }
1162 sinks:
1163 archive: { type: jsonl, config: { path: ./out.jsonl } }
1164matrix:
1165 - id: load_users
1166 source:
1167 ref: users_api
1168 config: { audit_url: "${sources.users_api.config.base_url}/audit" }
1169"#,
1170 );
1171 let row_src = cfg.matrix[0].source.as_ref().unwrap();
1172 assert_eq!(
1173 row_src.config.as_ref().unwrap()["audit_url"],
1174 "https://api.example.com/audit"
1175 );
1176 }
1177
1178 #[test]
1179 fn detects_vars_cycle() {
1180 let yaml = r#"
1181version: 1
1182vars:
1183 a: "${vars.b}"
1184 b: "${vars.c}"
1185 c: "${vars.a}"
1186pipeline:
1187 source: { type: rest, config: {} }
1188 sink: { type: jsonl, config: { path: ./o.jsonl } }
1189"#;
1190 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1192 match err {
1193 CliError::InterpolationCycle { chain } => {
1194 assert_eq!(chain.len(), 4, "chain: {chain:?}");
1197 assert_eq!(chain.first(), chain.last(), "chain: {chain:?}");
1198 let mut sorted_interior: Vec<_> = chain[..3].to_vec();
1200 sorted_interior.sort();
1201 assert_eq!(sorted_interior, vec!["vars.a", "vars.b", "vars.c"]);
1202 }
1203 other => panic!("expected InterpolationCycle, got {other:?}"),
1204 }
1205 }
1206
1207 #[test]
1208 fn resolves_cross_template_reference() {
1209 let cfg = load(
1211 r#"
1212version: 1
1213pipeline:
1214 sources:
1215 a: { type: rest, config: { host: api.example.com } }
1216 b: { type: rest, config: { host: "${sources.a.config.host}" } }
1217 sinks:
1218 out: { type: jsonl, config: { path: ./o.jsonl } }
1219"#,
1220 );
1221 assert_eq!(cfg.pipeline.sources["b"].config["host"], "api.example.com");
1222 }
1223
1224 #[test]
1225 fn resolves_chained_cross_template_reference() {
1226 let cfg = parse_with_extension(
1232 r#"
1233version: 1
1234pipeline:
1235 sources:
1236 a: { type: rest, config: { host: "${sources.b.config.host}" } }
1237 b: { type: rest, config: { host: "${sources.c.config.host}" } }
1238 c: { type: rest, config: { host: db.example.com } }
1239 sinks:
1240 out: { type: jsonl, config: { path: ./o.jsonl } }
1241"#,
1242 "yaml",
1243 )
1244 .unwrap();
1245 assert_eq!(cfg.pipeline.sources["a"].config["host"], "db.example.com");
1246 assert_eq!(cfg.pipeline.sources["b"].config["host"], "db.example.com");
1247 }
1248
1249 #[test]
1250 fn resolves_cross_template_reference_across_kinds() {
1251 let cfg = load(
1253 r#"
1254version: 1
1255pipeline:
1256 sources:
1257 api: { type: rest, config: { host: api.example.com } }
1258 sinks:
1259 mirror: { type: http, config: { url: "${sources.api.config.host}" } }
1260"#,
1261 );
1262 assert_eq!(
1263 cfg.pipeline.sinks["mirror"].config["url"],
1264 "api.example.com"
1265 );
1266 }
1267
1268 #[test]
1269 fn detects_cross_template_cycle() {
1270 let yaml = r#"
1273version: 1
1274pipeline:
1275 sources:
1276 a: { type: rest, config: { host: "${sources.b.config.host}" } }
1277 b: { type: rest, config: { host: "${sources.a.config.host}" } }
1278 sinks:
1279 out: { type: jsonl, config: { path: ./o.jsonl } }
1280"#;
1281 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1282 match err {
1283 CliError::InterpolationCycle { chain } => {
1284 assert!(chain.first() == chain.last(), "chain: {chain:?}");
1285 assert!(
1286 chain.iter().any(|c| c == "sources.a")
1287 && chain.iter().any(|c| c == "sources.b"),
1288 "chain must name both templates: {chain:?}"
1289 );
1290 }
1291 other => panic!("expected InterpolationCycle, got {other:?}"),
1292 }
1293 }
1294
1295 #[test]
1296 fn unknown_template_path_errors() {
1297 let yaml = r#"
1299version: 1
1300pipeline:
1301 sources:
1302 a: { type: rest, config: { host: x } }
1303 source: { type: rest, config: { x: "${sources.a.config.missing_field}" } }
1304 sink: { type: jsonl, config: { path: ./o.jsonl } }
1305"#;
1306 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1308 match err {
1309 CliError::UnknownTemplateRef { reason, .. } => {
1310 assert!(reason.contains("missing_field"));
1311 }
1312 other => panic!("expected UnknownTemplateRef, got {other:?}"),
1313 }
1314 }
1315
1316 #[test]
1317 fn unknown_var_errors() {
1318 let yaml = r#"
1319version: 1
1320pipeline:
1321 source: { type: rest, config: { url: "${vars.nope}" } }
1322 sink: { type: jsonl, config: { path: ./o.jsonl } }
1323"#;
1324 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1326 match err {
1327 CliError::UnknownVarsRef { name, .. } => assert_eq!(name, "nope"),
1328 other => panic!("expected UnknownVarsRef, got {other:?}"),
1329 }
1330 }
1331
1332 #[test]
1333 fn unknown_template_ref_errors() {
1334 let yaml = r#"
1335version: 1
1336pipeline:
1337 source: { type: rest, config: { x: "${sources.nope.config.foo}" } }
1338 sink: { type: jsonl, config: { path: ./o.jsonl } }
1339"#;
1340 let err = parse_with_extension(yaml, "yaml").unwrap_err();
1342 match err {
1343 CliError::UnknownTemplateRef { reason, .. } => {
1344 assert!(reason.to_ascii_lowercase().contains("nope"));
1345 }
1346 other => panic!("expected UnknownTemplateRef, got {other:?}"),
1347 }
1348 }
1349
1350 #[test]
1351 fn leaves_row_id_tokens_for_runtime() {
1352 let cfg = load(
1353 r#"
1354version: 1
1355pipeline:
1356 source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1357 sink: { type: jsonl, config: { path: ./o.jsonl } }
1358"#,
1359 );
1360 assert_eq!(
1362 cfg.pipeline.source.as_ref().unwrap().config["path"],
1363 "/v1/users/${users.id}/posts"
1364 );
1365 }
1366
1367 #[test]
1368 fn resolves_vars_inside_auth_catalog() {
1369 let cfg = load(
1372 r#"
1373version: 1
1374vars:
1375 idp_token: topsecret
1376auth:
1377 idp: { type: static, config: { token: "Bearer ${vars.idp_token}" } }
1378pipeline:
1379 source: { type: rest, config: { base_url: https://x } }
1380 sink: { type: jsonl, config: { path: ./o.jsonl } }
1381"#,
1382 );
1383 assert_eq!(
1384 cfg.auth.as_ref().unwrap()["idp"]["config"]["token"],
1385 "Bearer topsecret"
1386 );
1387 }
1388
1389 fn fixed_clock() -> chrono::DateTime<chrono::FixedOffset> {
1392 use chrono::TimeZone;
1393 chrono::FixedOffset::east_opt(0)
1395 .unwrap()
1396 .with_ymd_and_hms(2026, 3, 8, 14, 5, 9)
1397 .unwrap()
1398 }
1399
1400 #[test]
1401 fn now_named_tokens_render() {
1402 let c = fixed_clock();
1403 assert_eq!(resolve_now("${now.date}", c).unwrap(), "2026-03-08");
1404 assert_eq!(resolve_now("${now.year}", c).unwrap(), "2026");
1405 assert_eq!(resolve_now("${now.month}", c).unwrap(), "03");
1406 assert_eq!(resolve_now("${now.day}", c).unwrap(), "08");
1407 assert_eq!(resolve_now("${now.hour}", c).unwrap(), "14");
1408 assert_eq!(resolve_now("${now.minute}", c).unwrap(), "05");
1409 assert_eq!(resolve_now("${now.second}", c).unwrap(), "09");
1410 assert_eq!(
1411 resolve_now("${now.unix}", c).unwrap(),
1412 c.timestamp().to_string()
1413 );
1414 assert!(
1415 resolve_now("${now.iso}", c)
1416 .unwrap()
1417 .starts_with("2026-03-08T14:05:09")
1418 );
1419 assert_eq!(
1420 resolve_now("${now.datetime}", c).unwrap(),
1421 resolve_now("${now.iso}", c).unwrap()
1422 );
1423 }
1424
1425 #[test]
1426 fn now_in_a_path_template() {
1427 let c = fixed_clock();
1428 assert_eq!(
1429 resolve_now("s3://bucket/dt=${now.date}/part.jsonl", c).unwrap(),
1430 "s3://bucket/dt=2026-03-08/part.jsonl"
1431 );
1432 }
1433
1434 #[test]
1435 fn now_strftime_renders_and_rejects_bad_format() {
1436 let c = fixed_clock();
1437 assert_eq!(
1438 resolve_now("${now.strftime.%Y/%m/%d}", c).unwrap(),
1439 "2026/03/08"
1440 );
1441 let err = resolve_now("${now.strftime.%Q}", c).unwrap_err();
1444 assert!(err.to_string().contains("strftime"));
1445 }
1446
1447 #[test]
1448 fn now_unknown_token_errors() {
1449 let c = fixed_clock();
1450 let err = resolve_now("${now.bogus}", c).unwrap_err();
1451 assert!(err.to_string().contains("now.bogus"));
1452 }
1453
1454 #[test]
1455 fn now_leaves_other_tokens_verbatim() {
1456 let c = fixed_clock();
1457 assert_eq!(
1459 resolve_now("${env:VAR}/${users.id}/${now.date}", c).unwrap(),
1460 "${env:VAR}/${users.id}/2026-03-08"
1461 );
1462 }
1463
1464 #[test]
1465 fn resolves_lineage_job_name_tokens() {
1466 assert_eq!(
1467 resolve_lineage_job_name("${name}::${row_id}", "orders", "users"),
1468 "orders::users"
1469 );
1470 assert_eq!(
1471 resolve_lineage_job_name("static", "orders", "users"),
1472 "static"
1473 );
1474 }
1475}