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