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