Skip to main content

faucet_cli/
interpolate.rs

1//! Two-stage interpolation for pipeline configs.
2//!
3//! **Load-time** ([`interpolate`]) resolves directives that are knowable
4//! before any pipeline has run — environment variables, file contents,
5//! secrets. Tokens that don't match one of these prefixes are left literal
6//! so the matrix expander can later treat them as `${row_id.field.path}`
7//! deferred references.
8//!
9//! **Record-time** ([`interpolate_record`]) resolves the remaining
10//! `${row_id.dotted.path}` tokens against a context map of parent records,
11//! producing a string ready to feed into a connector's `Deserialize` impl.
12//!
13//! Supported load-time directives:
14//!
15//! | Form               | Resolves to |
16//! |--------------------|-------------|
17//! | `${env:VAR}`       | the value of environment variable `VAR` |
18//! | `${file:PATH}`     | the contents of the file at `PATH` (trimmed) |
19//! | `${secret:VAR}`    | alias for `${env:VAR}` (reserved for a future secrets backend) |
20//!
21//! Anything else (including `${users.id}`, `${posts.author.name}`) is
22//! deferred to record-time. A literal `${` is written `$${`.
23
24use crate::error::{CliError, CliResult};
25use chrono::{DateTime, FixedOffset};
26use serde_json::Value;
27use std::collections::HashMap;
28use std::path::PathBuf;
29
30/// An overlay consulted before the process environment when resolving
31/// `${env:VAR}` / `${secret:VAR}`. Populated by a caller-supplied `env:` map
32/// (`faucet run --param-env`, `POST /v1/templates/{id}/runs`) so a run can
33/// override a variable without mutating the shared process environment — which
34/// would be racy in a concurrent server (#444).
35pub type EnvOverlay = HashMap<String, String>;
36
37/// Resolve every load-time directive in `input`. Unknown prefixes (and
38/// tokens with no `:` at all — i.e. `${row_id.field}` references) survive
39/// verbatim for record-time resolution.
40pub fn interpolate(input: &str) -> CliResult<String> {
41    interpolate_with_env(input, &EnvOverlay::new())
42}
43
44/// [`interpolate`] with an [`EnvOverlay`] taking precedence over the process
45/// environment for `${env:}` / `${secret:}` lookups.
46pub 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                // Register the resolved value for redaction, exactly as the
58                // secrets-manager pass does for `${vault:…}` etc. — otherwise a
59                // credential supplied via the very common `${env:TOKEN}` /
60                // `${secret:VAR}` form leaks into tracing/log/error output while
61                // `${vault:…}` ones are scrubbed (an inconsistent boundary,
62                // #146 M3). `register` no-ops for values below the min length.
63                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            // Any other ${prefix:body} that isn't env/file/secret — leave it
72            // literal so a downstream validator can flag truly bogus prefixes.
73            _ => Ok(None),
74        },
75        Directive::Deferred { .. } => Ok(None),
76    })
77}
78
79/// Resolve load-time directives (`${env:}` / `${file:}` / `${secret:}`) inside
80/// an already-parsed config tree by running [`interpolate`] on every string
81/// scalar — object keys and string values, recursively.
82///
83/// This is the **structure-safe** counterpart to running [`interpolate`] on the
84/// raw document text: because substitution happens per-scalar, a resolved
85/// value containing markup-significant characters (`:`, newlines, `-`) can never
86/// inject a key, an array element, or otherwise alter the document's structure
87/// (F43) — it stays the single scalar it was parsed as, exactly like the
88/// secrets-manager pass. Deferred `${id.path}` / `${now.*}` tokens survive
89/// verbatim for their later resolution stages.
90pub fn interpolate_value(value: &mut Value) -> CliResult<()> {
91    interpolate_value_with_env(value, &EnvOverlay::new())
92}
93
94/// [`interpolate_value`] with an [`EnvOverlay`] taking precedence over the
95/// process environment.
96pub 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            // Keys may also carry directives (rare, but supported by the old
108            // raw-text pass). Rebuild the map so an interpolated key is honoured.
109            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
121/// Resolve `${id.dotted.path}` tokens against `ctx`. Tokens that look like
122/// load-time directives (`${env:...}`, `${file:...}`, `${secret:...}`) are
123/// left untouched — they should already have been resolved by [`interpolate`].
124///
125/// Errors when an `id` is unknown or a dotted path does not resolve.
126pub 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
146/// Render a resolved runtime value for substitution into a config field.
147///
148/// Like [`value_to_string`], but a JSON **array** renders as its scalar elements
149/// **comma-joined** (`["a","b","c"]` → `a,b,c`) rather than as JSON. This is the
150/// collected-dimension form (#531): a `collect: true` discovery publishes a list
151/// per tuple, and `${id.alias}` injects it as one param (e.g. HubSpot's
152/// `?properties=a,b,c`). Non-scalar array elements fall back to their JSON form.
153pub(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
164/// Resolve `${now.<token>}` references in `input` against the run clock. Every
165/// other `${...}` token (env/file/secret/vars/sources/sinks/row-id) is left
166/// verbatim. `now` is a reserved built-in id (see `expand.rs`). An unknown
167/// `${now.<bad>}` token is a config error (never silently passed through).
168pub 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
177/// Render one `${now.<path>}` token. `path` is the text after `now.`.
178fn now_token(path: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
179    // Arbitrary chrono strftime via the dot-form `${now.strftime.<fmt>}`.
180    if let Some(fmt) = path.strip_prefix("strftime.") {
181        // Pre-validate: a bad specifier yields `Item::Error`, and rendering it
182        // would panic in `to_string()`. Reject it as a config error instead.
183        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
211/// Walk `input` byte-by-byte, calling `resolve` on each `${...}` body.
212/// `resolve` returns `Some(s)` to substitute, or `None` to keep verbatim.
213pub(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        // Escape: `$${` → literal `${`.
222        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                // Unclosed directive — copy the rest verbatim and stop scanning.
231                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
250/// Classification of a `${...}` directive body. This is the **single** rule
251/// shared by load-time interpolation, record-time interpolation, and matrix
252/// validation (`expand.rs`) so they can never disagree about what a token
253/// means (#78/#39).
254///
255/// A load-time directive uses a colon (`${env:VAR}`, `${file:./p}`); a
256/// deferred reference uses a dot or nothing (`${users.id}`, `${row}`). The
257/// colon is checked first, so `${env.foo}` (no colon) is a *deferred*
258/// reference to id `env`, not a malformed load-time `env` directive — both
259/// the validator and the runtime now treat it identically.
260pub enum Directive<'a> {
261    /// Prefixed directive like `${env:VAR}` or `${file:./p}` — split on `:`.
262    LoadTime { prefix: &'a str, body: &'a str },
263    /// No `:` — a `${id.dotted.path}` reference deferred to runtime. `id` is
264    /// the text before the first `.`; `path` is the (possibly empty) rest.
265    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
278/// Iterate every `${...}` directive in `s`, yielding the full token text
279/// (including `${` and `}`) and its [`Directive`] classification. `$${` is an
280/// escape and yields nothing; an unterminated `${` ends iteration. This is
281/// the shared tokenizer used by `expand.rs` validation so it scans and
282/// classifies tokens exactly as `rewrite` does during substitution.
283pub 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            // `$${` escape — consume the `$$` (mirrors `rewrite`) and continue.
289            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
312/// Upper bound on a `${file:...}` read. The directive injects small token /
313/// secret / cert files into a config field; anything larger is almost
314/// certainly a misconfiguration (a data file, or `/dev/zero`, which would
315/// OOM an unbounded `fs::read`). Configs are trusted input, but a stray
316/// path shouldn't be able to exhaust memory (#78/#37).
317const MAX_INTERPOLATED_FILE_BYTES: u64 = 1024 * 1024; // 1 MiB
318
319fn 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    // Read at most MAX+1 bytes so we can detect (rather than truncate) an
327    // oversized file without ever allocating more than the cap.
328    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
344/// Walk a dotted path through a JSON value. Returns `None` if any segment
345/// is missing or addresses through a non-object/array node.
346fn 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
364/// Render a JSON value as a plain string suitable for substitution into a
365/// config field. Strings come through unquoted; everything else uses
366/// `to_string()` (numbers / bools / null / nested JSON).
367pub(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
374// ── Post-parse load-time ref resolution ─────────────────────────────────────
375
376/// Resolve every `${vars.X}`, `${sources.X.PATH}`, `${sinks.X.PATH}` token
377/// found in a parsed [`PipelineConfig`](crate::config::PipelineConfig).
378///
379/// Order:
380/// 1. Resolve `${vars.X}` references *inside the vars block itself* (with
381///    cycle detection), so vars-referencing-vars works.
382/// 2. Substitute `${vars.X}` inside `pipeline.sources.*` / `pipeline.sinks.*`
383///    template bodies, plus the legacy singular `pipeline.source` /
384///    `pipeline.sink` configs. This snapshot becomes the basis for
385///    `${sources.X.PATH}` / `${sinks.X.PATH}` lookups.
386/// 3. Walk every other string location in the config and resolve both
387///    `${vars.X}` and `${sources/sinks.X.PATH}`. `${row_id.path}` tokens
388///    are passed through verbatim for runtime resolution.
389///
390/// The legacy singular `pipeline.source` / `pipeline.sink` are visible under
391/// the template name `default` for `${sources.default.config.X}` /
392/// `${sinks.default.config.X}` lookups.
393pub fn resolve_config_refs(cfg: &mut crate::config::PipelineConfig) -> CliResult<()> {
394    // Phase 1: fully resolve the vars block (vars may reference other vars).
395    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    // Phase 2: substitute vars inside template bodies so the snapshot taken
404    // next sees fully-resolved values.
405    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    // Snapshot the templates *after* vars substitution.
419    let snapshot = TemplateSnapshot::capture(&cfg.pipeline);
420
421    // Phase 3: walk everything — including the template bodies again for
422    // ${sources/sinks.X.PATH} refs (Phase 2 only resolved ${vars.X} there).
423    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    // The shared `auth:` catalog is a first-class config location: provider
445    // specs may reference `${vars.X}` / `${sources.X.PATH}` like any other (#134).
446    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    // The replication snapshot source config is a first-class connector config
452    // (like `pipeline.source.config`); `${vars.X}` / `${sources.X.PATH}` refs
453    // there must resolve too.
454    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
484/// Snapshot of the resolved templates (vars already substituted) so that
485/// `${sources.X.PATH}` lookups can find values without re-walking the live config.
486struct 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
529/// Resolve the vars block in topological order, returning the fully-substituted
530/// map. Cycles surface as [`CliError::InterpolationCycle`].
531fn 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        // Already on the DFS stack — cycle detected. Build the chain in
551        // traversal order: the nodes from `start` to the end of the stack,
552        // plus the back-edge closing the cycle (key itself again).
553        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
571/// Phase 1 — vars may reference other vars. Resolves `${vars.X}` tokens in
572/// `v` and recursively resolves any vars that haven't been resolved yet.
573fn 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); // not a vars ref — leave verbatim
584                };
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
613/// Phase 2 — vars map already fully resolved. Resolves only `${vars.X}`
614/// tokens in `v` against the pre-resolved vars map; errors if a var is unknown.
615fn 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
645/// Phase 3 — vars and template refs. Resolves both `${vars.X}` and
646/// `${sources/sinks.X.PATH}` tokens in `v`; deferred row-id tokens pass through.
647fn 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                // Any other prefix (e.g. `${users.id}`) is a deferred row-id token —
683                // leave verbatim for runtime resolution.
684                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
703/// Resolve a `${sources.X.PATH}` / `${sinks.X.PATH}` reference, following
704/// template-to-template chains to their terminal literal.
705///
706/// `visiting` is the DFS stack of `{kind}.{name}` keys currently being
707/// expanded; re-encountering a key on the stack is a cycle (e.g. `a → b → a`)
708/// and surfaces as [`CliError::InterpolationCycle`] rather than silently
709/// leaving each template holding the other's token text (#78/#10).
710fn 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    // `rest` is `<name>` or `<name>.<dotted.path>`
718    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    // The looked-up value may itself contain `${sources/sinks.X}` tokens (a
740    // template referencing another template). Resolve them, following the
741    // chain, with `key` pushed so a cycle is detected. `${vars.X}` are already
742    // substituted (Phase 2); other prefixes are deferred row-id tokens and
743    // pass through verbatim.
744    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
763/// Resolve `${name}` and `${row_id}` in a lineage job-name template. `${now.*}`
764/// tokens are resolved earlier by the run-clock pass over the config.
765pub 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        // #531: a collected discovery dim resolves to a JSON array; the record
786        // interpolation renders it comma-joined so `?properties=a,b,c` works.
787        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        // Embedded in a larger string, too.
795        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); // non-strings untouched
838        assert_eq!(v["list"][2], true);
839        assert_eq!(v["deferred"], "${users.id}"); // deferred token survives
840        assert_eq!(v["num"], 5);
841        assert_eq!(v["ckey"], "kv"); // interpolated key
842        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        // F43: a resolved value containing markup-significant characters must
849        // NOT alter the document's structure — it stays one scalar string,
850        // unlike the old raw-text substitution which could inject a sibling key.
851        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        // The object still has exactly one key — nothing was injected.
856        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        // M3 (#146): credentials supplied via ${env:}/${secret:} must be
872        // scrubbed from faucet's tracing/log/error output, just like
873        // ${vault:…} values — not left to leak.
874        let secret = "super-secret-token-abcdef-1234567890"; // >= MIN_REDACT_LEN
875        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        // Regression for #78/#37: a file larger than the cap errors instead of
900        // being read unboundedly into memory.
901        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        // Exactly at the cap is allowed.
917        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        // Unknown prefixes are deferred — the matrix expander decides if
935        // they reference a real row id later. (Pre-#54 behaviour was to error
936        // here; that responsibility moves to expand.rs.)
937        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        // The single classification rule shared by interpolate + expand (#78/#39).
944        assert!(matches!(
945            classify_directive("env:VAR"),
946            Directive::LoadTime {
947                prefix: "env",
948                body: "VAR"
949            }
950        ));
951        // No colon → deferred, even for a reserved-looking prefix like `env`.
952        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        // The escaped $${lit} is not a token.
979        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    // ── record-time tests ───────────────────────────────────────────────
1012
1013    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    // ── post-parse load-time tests ───────────────────────────────────────
1079
1080    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        // `${vars.X}` in the replication snapshot source config must resolve at
1110        // load time, just like in `pipeline.source.config`.
1111        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        // resolve_config_refs now runs inside from_text; the error surfaces there.
1191        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1192        match err {
1193            CliError::InterpolationCycle { chain } => {
1194                // 3-node cycle a → b → c → a: chain should have 4 entries with
1195                // the first equal to the last (the closing back-edge).
1196                assert_eq!(chain.len(), 4, "chain: {chain:?}");
1197                assert_eq!(chain.first(), chain.last(), "chain: {chain:?}");
1198                // Every node appears exactly once interior, plus the closing edge.
1199                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        // sources.b references sources.a via ${sources.a.config.host}.
1210        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        // a → b → c, where c holds the literal. A single resolution pass (as
1227        // production runs via `from_text`) must follow the chain all the way
1228        // to the literal, not stop at b's token text (#78/#10). NB: we use
1229        // `parse_with_extension` directly (one pass) rather than the `load`
1230        // helper, which resolves twice and would mask a single-pass gap.
1231        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        // A sink template referencing a source template (cross-namespace).
1252        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        // a → b → a is a mutual cycle and must error, not silently leave each
1271        // template holding the other's literal token text (#78/#10).
1272        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        // sources.a exists, but its config has no `missing_field` path.
1298        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        // resolve_config_refs now runs inside from_text; the error surfaces there.
1307        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        // resolve_config_refs now runs inside from_text; the error surfaces there.
1325        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        // resolve_config_refs now runs inside from_text; the error surfaces there.
1341        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        // ${users.id} must survive — it's a deferred row-id reference.
1361        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        // The shared `auth:` catalog must be a first-class config location:
1370        // ${vars.X} (and ${sources/sinks.X.PATH}) resolve there too (#134).
1371        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    // ── resolve_now tests ────────────────────────────────────────────────────
1390
1391    fn fixed_clock() -> chrono::DateTime<chrono::FixedOffset> {
1392        use chrono::TimeZone;
1393        // 2026-03-08 14:05:09 +00:00
1394        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        // A bogus specifier must be a clean config error, NOT a panic.
1442        // `%Q` is not a valid strftime specifier in chrono and produces Item::Error.
1443        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        // env/row-id/vars tokens must survive the now-pass untouched.
1458        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}