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/// Resolve every load-time directive in `input`. Unknown prefixes (and
31/// tokens with no `:` at all — i.e. `${row_id.field}` references) survive
32/// verbatim for record-time resolution.
33pub 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                // Register the resolved value for redaction, exactly as the
42                // secrets-manager pass does for `${vault:…}` etc. — otherwise a
43                // credential supplied via the very common `${env:TOKEN}` /
44                // `${secret:VAR}` form leaks into tracing/log/error output while
45                // `${vault:…}` ones are scrubbed (an inconsistent boundary,
46                // #146 M3). `register` no-ops for values below the min length.
47                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            // Any other ${prefix:body} that isn't env/file/secret — leave it
56            // literal so a downstream validator can flag truly bogus prefixes.
57            _ => Ok(None),
58        },
59        Directive::Deferred { .. } => Ok(None),
60    })
61}
62
63/// Resolve load-time directives (`${env:}` / `${file:}` / `${secret:}`) inside
64/// an already-parsed config tree by running [`interpolate`] on every string
65/// scalar — object keys and string values, recursively.
66///
67/// This is the **structure-safe** counterpart to running [`interpolate`] on the
68/// raw document text: because substitution happens per-scalar, a resolved
69/// value containing markup-significant characters (`:`, newlines, `-`) can never
70/// inject a key, an array element, or otherwise alter the document's structure
71/// (F43) — it stays the single scalar it was parsed as, exactly like the
72/// secrets-manager pass. Deferred `${id.path}` / `${now.*}` tokens survive
73/// verbatim for their later resolution stages.
74pub fn interpolate_value(value: &mut Value) -> CliResult<()> {
75    match value {
76        Value::String(s) => {
77            *s = interpolate(s)?;
78        }
79        Value::Array(items) => {
80            for item in items {
81                interpolate_value(item)?;
82            }
83        }
84        Value::Object(map) => {
85            // Keys may also carry directives (rare, but supported by the old
86            // raw-text pass). Rebuild the map so an interpolated key is honoured.
87            let entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
88            for (key, mut val) in entries {
89                interpolate_value(&mut val)?;
90                let resolved_key = interpolate(&key)?;
91                map.insert(resolved_key, val);
92            }
93        }
94        _ => {}
95    }
96    Ok(())
97}
98
99/// Resolve `${id.dotted.path}` tokens against `ctx`. Tokens that look like
100/// load-time directives (`${env:...}`, `${file:...}`, `${secret:...}`) are
101/// left untouched — they should already have been resolved by [`interpolate`].
102///
103/// Errors when an `id` is unknown or a dotted path does not resolve.
104pub fn interpolate_record(input: &str, ctx: &HashMap<String, Value>) -> CliResult<String> {
105    rewrite(input, |body| match classify_directive(body) {
106        Directive::LoadTime { .. } => Ok(None),
107        Directive::Deferred { id, path } => {
108            let record = ctx
109                .get(id)
110                .ok_or_else(|| CliError::UnknownInterpolationId {
111                    id: id.to_owned(),
112                    token: format!("${{{body}}}"),
113                })?;
114            let resolved =
115                resolve_dotted(record, path).ok_or_else(|| CliError::MissingRecordField {
116                    id: id.to_owned(),
117                    path: path.to_owned(),
118                })?;
119            Ok(Some(value_to_string(&resolved)))
120        }
121    })
122}
123
124/// Resolve `${now.<token>}` references in `input` against the run clock. Every
125/// other `${...}` token (env/file/secret/vars/sources/sinks/row-id) is left
126/// verbatim. `now` is a reserved built-in id (see `expand.rs`). An unknown
127/// `${now.<bad>}` token is a config error (never silently passed through).
128pub fn resolve_now(input: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
129    rewrite(input, |body| {
130        if let Directive::Deferred { id: "now", path } = classify_directive(body) {
131            return Ok(Some(now_token(path, clock)?));
132        }
133        Ok(None)
134    })
135}
136
137/// Render one `${now.<path>}` token. `path` is the text after `now.`.
138fn now_token(path: &str, clock: DateTime<FixedOffset>) -> CliResult<String> {
139    // Arbitrary chrono strftime via the dot-form `${now.strftime.<fmt>}`.
140    if let Some(fmt) = path.strip_prefix("strftime.") {
141        // Pre-validate: a bad specifier yields `Item::Error`, and rendering it
142        // would panic in `to_string()`. Reject it as a config error instead.
143        use chrono::format::{Item, StrftimeItems};
144        let items: Vec<Item> = StrftimeItems::new(fmt).collect();
145        if items.iter().any(|i| matches!(i, Item::Error)) {
146            return Err(CliError::Config(format!(
147                "invalid strftime format in `${{now.strftime.{fmt}}}`"
148            )));
149        }
150        return Ok(clock.format_with_items(items.iter()).to_string());
151    }
152    let rendered = match path {
153        "date" => clock.format("%Y-%m-%d").to_string(),
154        "datetime" | "iso" => clock.to_rfc3339(),
155        "year" => clock.format("%Y").to_string(),
156        "month" => clock.format("%m").to_string(),
157        "day" => clock.format("%d").to_string(),
158        "hour" => clock.format("%H").to_string(),
159        "minute" => clock.format("%M").to_string(),
160        "second" => clock.format("%S").to_string(),
161        "unix" => clock.timestamp().to_string(),
162        other => {
163            return Err(CliError::Config(format!(
164                "unknown `${{now.{other}}}` token — valid: date, datetime, iso, year, month, day, hour, minute, second, unix, strftime.<fmt>"
165            )));
166        }
167    };
168    Ok(rendered)
169}
170
171/// Walk `input` byte-by-byte, calling `resolve` on each `${...}` body.
172/// `resolve` returns `Some(s)` to substitute, or `None` to keep verbatim.
173pub(crate) fn rewrite<F>(input: &str, mut resolve: F) -> CliResult<String>
174where
175    F: FnMut(&str) -> CliResult<Option<String>>,
176{
177    let mut out = String::with_capacity(input.len());
178    let bytes = input.as_bytes();
179    let mut i = 0;
180    while i < bytes.len() {
181        // Escape: `$${` → literal `${`.
182        if bytes[i] == b'$' && i + 2 < bytes.len() && bytes[i + 1] == b'$' && bytes[i + 2] == b'{' {
183            out.push('$');
184            i += 2;
185            continue;
186        }
187        if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
188            let start = i + 2;
189            let Some(rel_end) = input[start..].find('}') else {
190                // Unclosed directive — copy the rest verbatim and stop scanning.
191                out.push_str(&input[i..]);
192                break;
193            };
194            let end = start + rel_end;
195            let body = &input[start..end];
196            match resolve(body)? {
197                Some(s) => out.push_str(&s),
198                None => out.push_str(&input[i..=end]),
199            }
200            i = end + 1;
201            continue;
202        }
203        let ch = input[i..].chars().next().unwrap();
204        out.push(ch);
205        i += ch.len_utf8();
206    }
207    Ok(out)
208}
209
210/// Classification of a `${...}` directive body. This is the **single** rule
211/// shared by load-time interpolation, record-time interpolation, and matrix
212/// validation (`expand.rs`) so they can never disagree about what a token
213/// means (#78/#39).
214///
215/// A load-time directive uses a colon (`${env:VAR}`, `${file:./p}`); a
216/// deferred reference uses a dot or nothing (`${users.id}`, `${row}`). The
217/// colon is checked first, so `${env.foo}` (no colon) is a *deferred*
218/// reference to id `env`, not a malformed load-time `env` directive — both
219/// the validator and the runtime now treat it identically.
220pub enum Directive<'a> {
221    /// Prefixed directive like `${env:VAR}` or `${file:./p}` — split on `:`.
222    LoadTime { prefix: &'a str, body: &'a str },
223    /// No `:` — a `${id.dotted.path}` reference deferred to runtime. `id` is
224    /// the text before the first `.`; `path` is the (possibly empty) rest.
225    Deferred { id: &'a str, path: &'a str },
226}
227
228pub fn classify_directive(body: &str) -> Directive<'_> {
229    match body.split_once(':') {
230        Some((prefix, rest)) => Directive::LoadTime { prefix, body: rest },
231        None => {
232            let (id, path) = body.split_once('.').unwrap_or((body, ""));
233            Directive::Deferred { id, path }
234        }
235    }
236}
237
238/// Iterate every `${...}` directive in `s`, yielding the full token text
239/// (including `${` and `}`) and its [`Directive`] classification. `$${` is an
240/// escape and yields nothing; an unterminated `${` ends iteration. This is
241/// the shared tokenizer used by `expand.rs` validation so it scans and
242/// classifies tokens exactly as `rewrite` does during substitution.
243pub fn iter_directives(s: &str) -> impl Iterator<Item = (&str, Directive<'_>)> {
244    let bytes = s.as_bytes();
245    let mut i = 0;
246    std::iter::from_fn(move || {
247        while i < bytes.len() {
248            // `$${` escape — consume the `$$` (mirrors `rewrite`) and continue.
249            if bytes[i] == b'$'
250                && i + 2 < bytes.len()
251                && bytes[i + 1] == b'$'
252                && bytes[i + 2] == b'{'
253            {
254                i += 2;
255                continue;
256            }
257            if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
258                let start = i;
259                let body_start = i + 2;
260                let rel_end = s[body_start..].find('}')?;
261                let end = body_start + rel_end;
262                i = end + 1;
263                let body = &s[body_start..end];
264                return Some((&s[start..=end], classify_directive(body)));
265            }
266            i += 1;
267        }
268        None
269    })
270}
271
272/// Upper bound on a `${file:...}` read. The directive injects small token /
273/// secret / cert files into a config field; anything larger is almost
274/// certainly a misconfiguration (a data file, or `/dev/zero`, which would
275/// OOM an unbounded `fs::read`). Configs are trusted input, but a stray
276/// path shouldn't be able to exhaust memory (#78/#37).
277const MAX_INTERPOLATED_FILE_BYTES: u64 = 1024 * 1024; // 1 MiB
278
279fn read_file_trimmed(path_str: &str) -> CliResult<String> {
280    use std::io::Read as _;
281    let path = PathBuf::from(path_str);
282    let file = std::fs::File::open(&path).map_err(|source| CliError::ReadInterpolatedFile {
283        path: path.clone(),
284        source,
285    })?;
286    // Read at most MAX+1 bytes so we can detect (rather than truncate) an
287    // oversized file without ever allocating more than the cap.
288    let mut buf = Vec::new();
289    file.take(MAX_INTERPOLATED_FILE_BYTES + 1)
290        .read_to_end(&mut buf)
291        .map_err(|source| CliError::ReadInterpolatedFile {
292            path: path.clone(),
293            source,
294        })?;
295    if buf.len() as u64 > MAX_INTERPOLATED_FILE_BYTES {
296        return Err(CliError::InterpolatedFileTooLarge {
297            path,
298            max_bytes: MAX_INTERPOLATED_FILE_BYTES,
299        });
300    }
301    Ok(String::from_utf8_lossy(&buf).trim_end().to_owned())
302}
303
304/// Walk a dotted path through a JSON value. Returns `None` if any segment
305/// is missing or addresses through a non-object/array node.
306fn resolve_dotted(root: &Value, path: &str) -> Option<Value> {
307    if path.is_empty() {
308        return Some(root.clone());
309    }
310    let mut cur = root;
311    for segment in path.split('.') {
312        cur = match cur {
313            Value::Object(map) => map.get(segment)?,
314            Value::Array(arr) => {
315                let idx: usize = segment.parse().ok()?;
316                arr.get(idx)?
317            }
318            _ => return None,
319        };
320    }
321    Some(cur.clone())
322}
323
324/// Render a JSON value as a plain string suitable for substitution into a
325/// config field. Strings come through unquoted; everything else uses
326/// `to_string()` (numbers / bools / null / nested JSON).
327fn value_to_string(v: &Value) -> String {
328    match v {
329        Value::String(s) => s.clone(),
330        other => other.to_string(),
331    }
332}
333
334// ── Post-parse load-time ref resolution ─────────────────────────────────────
335
336/// Resolve every `${vars.X}`, `${sources.X.PATH}`, `${sinks.X.PATH}` token
337/// found in a parsed [`PipelineConfig`](crate::config::PipelineConfig).
338///
339/// Order:
340/// 1. Resolve `${vars.X}` references *inside the vars block itself* (with
341///    cycle detection), so vars-referencing-vars works.
342/// 2. Substitute `${vars.X}` inside `pipeline.sources.*` / `pipeline.sinks.*`
343///    template bodies, plus the legacy singular `pipeline.source` /
344///    `pipeline.sink` configs. This snapshot becomes the basis for
345///    `${sources.X.PATH}` / `${sinks.X.PATH}` lookups.
346/// 3. Walk every other string location in the config and resolve both
347///    `${vars.X}` and `${sources/sinks.X.PATH}`. `${row_id.path}` tokens
348///    are passed through verbatim for runtime resolution.
349///
350/// The legacy singular `pipeline.source` / `pipeline.sink` are visible under
351/// the template name `default` for `${sources.default.config.X}` /
352/// `${sinks.default.config.X}` lookups.
353pub fn resolve_config_refs(cfg: &mut crate::config::PipelineConfig) -> CliResult<()> {
354    // Phase 1: fully resolve the vars block (vars may reference other vars).
355    if let Some(vars) = cfg.vars.clone() {
356        let resolved = resolve_vars_block(&vars)?;
357        cfg.vars = Some(resolved);
358    }
359
360    let empty_vars: HashMap<String, Value> = HashMap::new();
361    let vars_ref: &HashMap<String, Value> = cfg.vars.as_ref().unwrap_or(&empty_vars);
362
363    // Phase 2: substitute vars inside template bodies so the snapshot taken
364    // next sees fully-resolved values.
365    for (_name, spec) in cfg.pipeline.sources.iter_mut() {
366        resolve_vars_only(&mut spec.config, vars_ref)?;
367    }
368    for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
369        resolve_vars_only(&mut spec.config, vars_ref)?;
370    }
371    if let Some(spec) = cfg.pipeline.source.as_mut() {
372        resolve_vars_only(&mut spec.config, vars_ref)?;
373    }
374    if let Some(spec) = cfg.pipeline.sink.as_mut() {
375        resolve_vars_only(&mut spec.config, vars_ref)?;
376    }
377
378    // Snapshot the templates *after* vars substitution.
379    let snapshot = TemplateSnapshot::capture(&cfg.pipeline);
380
381    // Phase 3: walk everything — including the template bodies again for
382    // ${sources/sinks.X.PATH} refs (Phase 2 only resolved ${vars.X} there).
383    for (_name, spec) in cfg.pipeline.sources.iter_mut() {
384        resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
385    }
386    for (_name, spec) in cfg.pipeline.sinks.iter_mut() {
387        resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
388    }
389    if let Some(spec) = cfg.pipeline.source.as_mut() {
390        resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
391    }
392    if let Some(spec) = cfg.pipeline.sink.as_mut() {
393        resolve_value_full(&mut spec.config, vars_ref, &snapshot)?;
394    }
395    for t in cfg.pipeline.transforms.iter_mut() {
396        resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
397    }
398    if let Some(s) = cfg.pipeline.state.as_mut() {
399        resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
400    }
401    if let Some(d) = cfg.pipeline.dlq.as_mut() {
402        resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
403    }
404    // The shared `auth:` catalog is a first-class config location: provider
405    // specs may reference `${vars.X}` / `${sources.X.PATH}` like any other (#134).
406    if let Some(auth) = cfg.auth.as_mut() {
407        for (_name, spec) in auth.iter_mut() {
408            resolve_value_full(spec, vars_ref, &snapshot)?;
409        }
410    }
411    // The replication snapshot source config is a first-class connector config
412    // (like `pipeline.source.config`); `${vars.X}` / `${sources.X.PATH}` refs
413    // there must resolve too.
414    if let Some(r) = cfg.replication.as_mut() {
415        resolve_value_full(&mut r.snapshot.source.config, vars_ref, &snapshot)?;
416    }
417    for (i, row) in cfg.matrix.iter_mut().enumerate() {
418        let _row_owner = row.id.clone().unwrap_or_else(|| format!("row-{i}"));
419        if let Some(p) = row.source.as_mut()
420            && let Some(c) = p.config.as_mut()
421        {
422            resolve_value_full(c, vars_ref, &snapshot)?;
423        }
424        if let Some(p) = row.sink.as_mut()
425            && let Some(c) = p.config.as_mut()
426        {
427            resolve_value_full(c, vars_ref, &snapshot)?;
428        }
429        if let Some(ts) = row.transforms.as_mut() {
430            for t in ts.iter_mut() {
431                resolve_value_full(&mut t.config, vars_ref, &snapshot)?;
432            }
433        }
434        if let Some(s) = row.state.as_mut() {
435            resolve_value_full(&mut s.config, vars_ref, &snapshot)?;
436        }
437        if let Some(Some(d)) = row.dlq.as_mut() {
438            resolve_value_full(&mut d.sink.config, vars_ref, &snapshot)?;
439        }
440    }
441    Ok(())
442}
443
444/// Snapshot of the resolved templates (vars already substituted) so that
445/// `${sources.X.PATH}` lookups can find values without re-walking the live config.
446struct TemplateSnapshot {
447    sources: HashMap<String, Value>,
448    sinks: HashMap<String, Value>,
449}
450
451impl TemplateSnapshot {
452    fn capture(spec: &crate::config::PipelineSpec) -> Self {
453        let mut sources: HashMap<String, Value> = spec
454            .sources
455            .iter()
456            .map(|(k, v)| {
457                (
458                    k.clone(),
459                    serde_json::to_value(v)
460                        .expect("ConnectorSpec derives Serialize and cannot fail"),
461                )
462            })
463            .collect();
464        if let Some(s) = &spec.source {
465            sources.entry("default".into()).or_insert_with(|| {
466                serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
467            });
468        }
469        let mut sinks: HashMap<String, Value> = spec
470            .sinks
471            .iter()
472            .map(|(k, v)| {
473                (
474                    k.clone(),
475                    serde_json::to_value(v)
476                        .expect("ConnectorSpec derives Serialize and cannot fail"),
477                )
478            })
479            .collect();
480        if let Some(s) = &spec.sink {
481            sinks.entry("default".into()).or_insert_with(|| {
482                serde_json::to_value(s).expect("ConnectorSpec derives Serialize and cannot fail")
483            });
484        }
485        Self { sources, sinks }
486    }
487}
488
489/// Resolve the vars block in topological order, returning the fully-substituted
490/// map. Cycles surface as [`CliError::InterpolationCycle`].
491fn resolve_vars_block(input: &HashMap<String, Value>) -> CliResult<HashMap<String, Value>> {
492    let mut resolved: HashMap<String, Value> = HashMap::new();
493    let mut visiting: Vec<String> = Vec::new();
494    for key in input.keys() {
495        resolve_one_var(key, input, &mut resolved, &mut visiting)?;
496    }
497    Ok(resolved)
498}
499
500fn resolve_one_var(
501    key: &str,
502    input: &HashMap<String, Value>,
503    resolved: &mut HashMap<String, Value>,
504    visiting: &mut Vec<String>,
505) -> CliResult<()> {
506    if resolved.contains_key(key) {
507        return Ok(());
508    }
509    if let Some(start) = visiting.iter().position(|k| k == key) {
510        // Already on the DFS stack — cycle detected. Build the chain in
511        // traversal order: the nodes from `start` to the end of the stack,
512        // plus the back-edge closing the cycle (key itself again).
513        let chain: Vec<String> = visiting[start..]
514            .iter()
515            .map(|k| format!("vars.{k}"))
516            .chain(std::iter::once(format!("vars.{key}")))
517            .collect();
518        return Err(CliError::InterpolationCycle { chain });
519    }
520    visiting.push(key.to_string());
521    let mut value = input
522        .get(key)
523        .expect("key was taken from input map")
524        .clone();
525    resolve_vars_recursive(&mut value, input, resolved, visiting)?;
526    visiting.pop();
527    resolved.insert(key.to_string(), value);
528    Ok(())
529}
530
531/// Phase 1 — vars may reference other vars. Resolves `${vars.X}` tokens in
532/// `v` and recursively resolves any vars that haven't been resolved yet.
533fn resolve_vars_recursive(
534    v: &mut Value,
535    input: &HashMap<String, Value>,
536    resolved: &mut HashMap<String, Value>,
537    visiting: &mut Vec<String>,
538) -> CliResult<()> {
539    match v {
540        Value::String(s) => {
541            let new_s = rewrite(s, |body| {
542                let Some(name) = body.strip_prefix("vars.") else {
543                    return Ok(None); // not a vars ref — leave verbatim
544                };
545                if !resolved.contains_key(name) {
546                    if !input.contains_key(name) {
547                        return Err(CliError::UnknownVarsRef {
548                            name: name.to_string(),
549                            token: format!("${{{body}}}"),
550                        });
551                    }
552                    resolve_one_var(name, input, resolved, visiting)?;
553                }
554                Ok(Some(value_to_string(&resolved[name])))
555            })?;
556            *s = new_s;
557        }
558        Value::Array(a) => {
559            for item in a.iter_mut() {
560                resolve_vars_recursive(item, input, resolved, visiting)?;
561            }
562        }
563        Value::Object(m) => {
564            for item in m.values_mut() {
565                resolve_vars_recursive(item, input, resolved, visiting)?;
566            }
567        }
568        _ => {}
569    }
570    Ok(())
571}
572
573/// Phase 2 — vars map already fully resolved. Resolves only `${vars.X}`
574/// tokens in `v` against the pre-resolved vars map; errors if a var is unknown.
575fn resolve_vars_only(v: &mut Value, vars: &HashMap<String, Value>) -> CliResult<()> {
576    match v {
577        Value::String(s) => {
578            let new_s = rewrite(s, |body| {
579                let Some(name) = body.strip_prefix("vars.") else {
580                    return Ok(None);
581                };
582                let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
583                    name: name.to_string(),
584                    token: format!("${{{body}}}"),
585                })?;
586                Ok(Some(value_to_string(val)))
587            })?;
588            *s = new_s;
589        }
590        Value::Array(a) => {
591            for item in a.iter_mut() {
592                resolve_vars_only(item, vars)?;
593            }
594        }
595        Value::Object(m) => {
596            for item in m.values_mut() {
597                resolve_vars_only(item, vars)?;
598            }
599        }
600        _ => {}
601    }
602    Ok(())
603}
604
605/// Phase 3 — vars and template refs. Resolves both `${vars.X}` and
606/// `${sources/sinks.X.PATH}` tokens in `v`; deferred row-id tokens pass through.
607fn resolve_value_full(
608    v: &mut Value,
609    vars: &HashMap<String, Value>,
610    templates: &TemplateSnapshot,
611) -> CliResult<()> {
612    match v {
613        Value::String(s) => {
614            let new_s = rewrite(s, |body| {
615                if let Some(name) = body.strip_prefix("vars.") {
616                    let val = vars.get(name).ok_or_else(|| CliError::UnknownVarsRef {
617                        name: name.to_string(),
618                        token: format!("${{{body}}}"),
619                    })?;
620                    return Ok(Some(value_to_string(val)));
621                }
622                if let Some(rest) = body.strip_prefix("sources.") {
623                    let mut visiting = Vec::new();
624                    return Ok(Some(lookup_template_path(
625                        &templates.sources,
626                        &templates.sinks,
627                        "sources",
628                        rest,
629                        &mut visiting,
630                    )?));
631                }
632                if let Some(rest) = body.strip_prefix("sinks.") {
633                    let mut visiting = Vec::new();
634                    return Ok(Some(lookup_template_path(
635                        &templates.sources,
636                        &templates.sinks,
637                        "sinks",
638                        rest,
639                        &mut visiting,
640                    )?));
641                }
642                // Any other prefix (e.g. `${users.id}`) is a deferred row-id token —
643                // leave verbatim for runtime resolution.
644                Ok(None)
645            })?;
646            *s = new_s;
647        }
648        Value::Array(a) => {
649            for item in a.iter_mut() {
650                resolve_value_full(item, vars, templates)?;
651            }
652        }
653        Value::Object(m) => {
654            for item in m.values_mut() {
655                resolve_value_full(item, vars, templates)?;
656            }
657        }
658        _ => {}
659    }
660    Ok(())
661}
662
663/// Resolve a `${sources.X.PATH}` / `${sinks.X.PATH}` reference, following
664/// template-to-template chains to their terminal literal.
665///
666/// `visiting` is the DFS stack of `{kind}.{name}` keys currently being
667/// expanded; re-encountering a key on the stack is a cycle (e.g. `a → b → a`)
668/// and surfaces as [`CliError::InterpolationCycle`] rather than silently
669/// leaving each template holding the other's token text (#78/#10).
670fn lookup_template_path(
671    sources: &HashMap<String, Value>,
672    sinks: &HashMap<String, Value>,
673    kind: &str,
674    rest: &str,
675    visiting: &mut Vec<String>,
676) -> CliResult<String> {
677    // `rest` is `<name>` or `<name>.<dotted.path>`
678    let (name, path) = rest.split_once('.').unwrap_or((rest, ""));
679    let key = format!("{kind}.{name}");
680    if let Some(start) = visiting.iter().position(|k| *k == key) {
681        let chain: Vec<String> = visiting[start..]
682            .iter()
683            .cloned()
684            .chain(std::iter::once(key))
685            .collect();
686        return Err(CliError::InterpolationCycle { chain });
687    }
688    let catalog = if kind == "sources" { sources } else { sinks };
689    let template = catalog
690        .get(name)
691        .ok_or_else(|| CliError::UnknownTemplateRef {
692            token: format!("${{{kind}.{rest}}}"),
693            reason: format!("no {kind} template named '{name}'"),
694        })?;
695    let resolved = resolve_dotted(template, path).ok_or_else(|| CliError::UnknownTemplateRef {
696        token: format!("${{{kind}.{rest}}}"),
697        reason: format!("path '{path}' does not resolve inside {kind} template '{name}'"),
698    })?;
699    // The looked-up value may itself contain `${sources/sinks.X}` tokens (a
700    // template referencing another template). Resolve them, following the
701    // chain, with `key` pushed so a cycle is detected. `${vars.X}` are already
702    // substituted (Phase 2); other prefixes are deferred row-id tokens and
703    // pass through verbatim.
704    let resolved_str = value_to_string(&resolved);
705    visiting.push(key);
706    let out = rewrite(&resolved_str, |body| {
707        if let Some(rest) = body.strip_prefix("sources.") {
708            return Ok(Some(lookup_template_path(
709                sources, sinks, "sources", rest, visiting,
710            )?));
711        }
712        if let Some(rest) = body.strip_prefix("sinks.") {
713            return Ok(Some(lookup_template_path(
714                sources, sinks, "sinks", rest, visiting,
715            )?));
716        }
717        Ok(None)
718    });
719    visiting.pop();
720    out
721}
722
723/// Resolve `${name}` and `${row_id}` in a lineage job-name template. `${now.*}`
724/// tokens are resolved earlier by the run-clock pass over the config.
725pub fn resolve_lineage_job_name(template: &str, name: &str, row_id: &str) -> String {
726    template
727        .replace("${name}", name)
728        .replace("${row_id}", row_id)
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734    use serde_json::json;
735    use std::collections::HashMap;
736
737    #[test]
738    fn passes_through_text_with_no_directives() {
739        let out = interpolate("just a string").unwrap();
740        assert_eq!(out, "just a string");
741    }
742
743    #[test]
744    fn substitutes_env_var() {
745        unsafe { std::env::set_var("FAUCET_TEST_VAR", "hello") };
746        let out = interpolate("token=${env:FAUCET_TEST_VAR}").unwrap();
747        assert_eq!(out, "token=hello");
748        unsafe { std::env::remove_var("FAUCET_TEST_VAR") };
749    }
750
751    #[test]
752    fn missing_env_var_is_an_error() {
753        unsafe { std::env::remove_var("FAUCET_TEST_MISSING") };
754        let err = interpolate("token=${env:FAUCET_TEST_MISSING}").unwrap_err();
755        match err {
756            CliError::MissingEnvVar { var, .. } => assert_eq!(var, "FAUCET_TEST_MISSING"),
757            other => panic!("expected MissingEnvVar, got {other:?}"),
758        }
759    }
760
761    #[test]
762    fn interpolate_value_resolves_scalars_in_strings_keys_and_arrays() {
763        unsafe { std::env::set_var("FAUCET_F43_TOKEN", "sekret") };
764        let mut v = json!({
765            "${env:FAUCET_F43_KEY}": "kv",
766            "auth": {"token": "${env:FAUCET_F43_TOKEN}"},
767            "list": ["${env:FAUCET_F43_TOKEN}", 42, true],
768            "deferred": "${users.id}",
769            "num": 5,
770        });
771        unsafe { std::env::set_var("FAUCET_F43_KEY", "ckey") };
772        interpolate_value(&mut v).unwrap();
773        assert_eq!(v["auth"]["token"], "sekret");
774        assert_eq!(v["list"][0], "sekret");
775        assert_eq!(v["list"][1], 42); // non-strings untouched
776        assert_eq!(v["list"][2], true);
777        assert_eq!(v["deferred"], "${users.id}"); // deferred token survives
778        assert_eq!(v["num"], 5);
779        assert_eq!(v["ckey"], "kv"); // interpolated key
780        unsafe { std::env::remove_var("FAUCET_F43_TOKEN") };
781        unsafe { std::env::remove_var("FAUCET_F43_KEY") };
782    }
783
784    #[test]
785    fn interpolate_value_keeps_resolved_value_as_a_single_scalar() {
786        // F43: a resolved value containing markup-significant characters must
787        // NOT alter the document's structure — it stays one scalar string,
788        // unlike the old raw-text substitution which could inject a sibling key.
789        unsafe { std::env::set_var("FAUCET_F43_INJECT", "real\ninjected_key: pwned\nmore: x") };
790        let mut v = json!({ "name": "${env:FAUCET_F43_INJECT}" });
791        interpolate_value(&mut v).unwrap();
792        assert_eq!(v["name"], "real\ninjected_key: pwned\nmore: x");
793        // The object still has exactly one key — nothing was injected.
794        assert_eq!(v.as_object().unwrap().len(), 1);
795        assert!(v.get("injected_key").is_none());
796        unsafe { std::env::remove_var("FAUCET_F43_INJECT") };
797    }
798
799    #[test]
800    fn secret_prefix_is_env_alias_for_now() {
801        unsafe { std::env::set_var("FAUCET_SECRET_VAR", "shh") };
802        let out = interpolate("${secret:FAUCET_SECRET_VAR}").unwrap();
803        assert_eq!(out, "shh");
804        unsafe { std::env::remove_var("FAUCET_SECRET_VAR") };
805    }
806
807    #[test]
808    fn resolved_env_and_secret_values_are_registered_for_redaction() {
809        // M3 (#146): credentials supplied via ${env:}/${secret:} must be
810        // scrubbed from faucet's tracing/log/error output, just like
811        // ${vault:…} values — not left to leak.
812        let secret = "super-secret-token-abcdef-1234567890"; // >= MIN_REDACT_LEN
813        unsafe { std::env::set_var("FAUCET_M3_REDACT_TOKEN", secret) };
814        let out = interpolate("Authorization: Bearer ${env:FAUCET_M3_REDACT_TOKEN}").unwrap();
815        assert!(out.contains(secret));
816        let redacted = crate::secrets::registry::redact(&out);
817        assert!(
818            !redacted.contains(secret),
819            "resolved ${{env:}} value must be registered for redaction"
820        );
821        assert!(redacted.contains("***"));
822        unsafe { std::env::remove_var("FAUCET_M3_REDACT_TOKEN") };
823    }
824
825    #[test]
826    fn reads_file_directive_and_trims_trailing_newline() {
827        let dir = tempfile::tempdir().unwrap();
828        let path = dir.path().join("token.txt");
829        std::fs::write(&path, "abcdef\n").unwrap();
830        let raw = format!("token=${{file:{}}}", path.display());
831        let out = interpolate(&raw).unwrap();
832        assert_eq!(out, "token=abcdef");
833    }
834
835    #[test]
836    fn file_directive_rejects_oversized_file() {
837        // Regression for #78/#37: a file larger than the cap errors instead of
838        // being read unboundedly into memory.
839        let dir = tempfile::tempdir().unwrap();
840        let path = dir.path().join("big.bin");
841        let big = vec![b'x'; (MAX_INTERPOLATED_FILE_BYTES + 10) as usize];
842        std::fs::write(&path, &big).unwrap();
843        let raw = format!("${{file:{}}}", path.display());
844        match interpolate(&raw).unwrap_err() {
845            CliError::InterpolatedFileTooLarge { max_bytes, .. } => {
846                assert_eq!(max_bytes, MAX_INTERPOLATED_FILE_BYTES);
847            }
848            other => panic!("expected InterpolatedFileTooLarge, got {other:?}"),
849        }
850    }
851
852    #[test]
853    fn file_directive_reads_file_at_the_limit() {
854        // Exactly at the cap is allowed.
855        let dir = tempfile::tempdir().unwrap();
856        let path = dir.path().join("ok.bin");
857        std::fs::write(&path, vec![b'a'; MAX_INTERPOLATED_FILE_BYTES as usize]).unwrap();
858        let raw = format!("${{file:{}}}", path.display());
859        assert!(interpolate(&raw).is_ok());
860    }
861
862    #[test]
863    fn load_time_leaves_id_path_tokens_alone() {
864        unsafe { std::env::set_var("FAUCET_T", "v") };
865        let out = interpolate("a=${env:FAUCET_T} b=${users.id}").unwrap();
866        assert_eq!(out, "a=v b=${users.id}");
867        unsafe { std::env::remove_var("FAUCET_T") };
868    }
869
870    #[test]
871    fn load_time_passes_unknown_prefix_through() {
872        // Unknown prefixes are deferred — the matrix expander decides if
873        // they reference a real row id later. (Pre-#54 behaviour was to error
874        // here; that responsibility moves to expand.rs.)
875        let out = interpolate("${weird:thing}").unwrap();
876        assert_eq!(out, "${weird:thing}");
877    }
878
879    #[test]
880    fn classify_colon_is_load_time_dot_is_deferred() {
881        // The single classification rule shared by interpolate + expand (#78/#39).
882        assert!(matches!(
883            classify_directive("env:VAR"),
884            Directive::LoadTime {
885                prefix: "env",
886                body: "VAR"
887            }
888        ));
889        // No colon → deferred, even for a reserved-looking prefix like `env`.
890        assert!(matches!(
891            classify_directive("env.foo"),
892            Directive::Deferred {
893                id: "env",
894                path: "foo"
895            }
896        ));
897        assert!(matches!(
898            classify_directive("users.addr.city"),
899            Directive::Deferred {
900                id: "users",
901                path: "addr.city"
902            }
903        ));
904        assert!(matches!(
905            classify_directive("row"),
906            Directive::Deferred {
907                id: "row",
908                path: ""
909            }
910        ));
911    }
912
913    #[test]
914    fn iter_directives_finds_tokens_and_skips_escapes() {
915        let toks: Vec<_> = iter_directives("a=${env:V} b=${users.id} c=$${lit}").collect();
916        // The escaped $${lit} is not a token.
917        assert_eq!(toks.len(), 2);
918        assert_eq!(toks[0].0, "${env:V}");
919        assert!(matches!(
920            toks[0].1,
921            Directive::LoadTime { prefix: "env", .. }
922        ));
923        assert_eq!(toks[1].0, "${users.id}");
924        assert!(matches!(toks[1].1, Directive::Deferred { id: "users", .. }));
925    }
926
927    #[test]
928    fn dollar_dollar_brace_is_escaped() {
929        let out = interpolate("path=$${env:VAR}").unwrap();
930        assert_eq!(out, "path=${env:VAR}");
931    }
932
933    #[test]
934    fn unclosed_directive_is_left_literal() {
935        let out = interpolate("hello ${env:NOPE").unwrap();
936        assert_eq!(out, "hello ${env:NOPE");
937    }
938
939    #[test]
940    fn multiple_directives_resolve_in_order() {
941        unsafe { std::env::set_var("FAUCET_A", "one") };
942        unsafe { std::env::set_var("FAUCET_B", "two") };
943        let out = interpolate("${env:FAUCET_A}-${env:FAUCET_B}").unwrap();
944        assert_eq!(out, "one-two");
945        unsafe { std::env::remove_var("FAUCET_A") };
946        unsafe { std::env::remove_var("FAUCET_B") };
947    }
948
949    // ── record-time tests ───────────────────────────────────────────────
950
951    fn ctx_with(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
952        pairs
953            .iter()
954            .map(|(k, v)| ((*k).into(), v.clone()))
955            .collect()
956    }
957
958    #[test]
959    fn record_resolves_simple_dotted_path() {
960        let ctx = ctx_with(&[("users", json!({"id": 42, "name": "alice"}))]);
961        let out = interpolate_record("/v1/users/${users.id}", &ctx).unwrap();
962        assert_eq!(out, "/v1/users/42");
963    }
964
965    #[test]
966    fn record_resolves_nested_dotted_path() {
967        let ctx = ctx_with(&[(
968            "users",
969            json!({"id": 1, "addr": {"city": "NYC", "zip": "10001"}}),
970        )]);
971        let out = interpolate_record("/${users.addr.city}/${users.addr.zip}", &ctx).unwrap();
972        assert_eq!(out, "/NYC/10001");
973    }
974
975    #[test]
976    fn record_resolves_array_index() {
977        let ctx = ctx_with(&[("users", json!({"tags": ["a", "b", "c"]}))]);
978        let out = interpolate_record("first=${users.tags.0}", &ctx).unwrap();
979        assert_eq!(out, "first=a");
980    }
981
982    #[test]
983    fn record_renders_numbers_and_booleans_as_strings() {
984        let ctx = ctx_with(&[("users", json!({"id": 7, "active": true}))]);
985        let out = interpolate_record("id=${users.id} active=${users.active}", &ctx).unwrap();
986        assert_eq!(out, "id=7 active=true");
987    }
988
989    #[test]
990    fn record_unknown_id_errors() {
991        let ctx = ctx_with(&[("users", json!({"id": 1}))]);
992        let err = interpolate_record("${nobody.x}", &ctx).unwrap_err();
993        assert!(matches!(err, CliError::UnknownInterpolationId { .. }));
994    }
995
996    #[test]
997    fn record_missing_field_errors() {
998        let ctx = ctx_with(&[("users", json!({"id": 1}))]);
999        let err = interpolate_record("${users.missing}", &ctx).unwrap_err();
1000        match err {
1001            CliError::MissingRecordField { id, path } => {
1002                assert_eq!(id, "users");
1003                assert_eq!(path, "missing");
1004            }
1005            other => panic!("expected MissingRecordField, got {other:?}"),
1006        }
1007    }
1008
1009    #[test]
1010    fn record_leaves_load_time_directives_alone() {
1011        let ctx = HashMap::new();
1012        let out = interpolate_record("a=${env:NOPE} b=${file:./x}", &ctx).unwrap();
1013        assert_eq!(out, "a=${env:NOPE} b=${file:./x}");
1014    }
1015
1016    // ── post-parse load-time tests ───────────────────────────────────────
1017
1018    use crate::config::{PipelineConfig, parse_with_extension};
1019    use crate::interpolate::resolve_config_refs;
1020
1021    fn load(yaml: &str) -> PipelineConfig {
1022        let mut cfg = parse_with_extension(yaml, "yaml").unwrap();
1023        resolve_config_refs(&mut cfg).unwrap();
1024        cfg
1025    }
1026
1027    #[test]
1028    fn resolves_vars_in_source_config() {
1029        let cfg = load(
1030            r#"
1031version: 1
1032vars:
1033  base: https://api.example.com
1034pipeline:
1035  source: { type: rest, config: { base_url: "${vars.base}" } }
1036  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1037"#,
1038        );
1039        assert_eq!(
1040            cfg.pipeline.source.as_ref().unwrap().config["base_url"],
1041            "https://api.example.com"
1042        );
1043    }
1044
1045    #[test]
1046    fn resolves_vars_in_replication_snapshot_source_config() {
1047        // `${vars.X}` in the replication snapshot source config must resolve at
1048        // load time, just like in `pipeline.source.config`.
1049        let cfg = load(
1050            r#"
1051version: 1
1052vars:
1053  base: postgres://db/orders
1054pipeline:
1055  source: { type: postgres-cdc, config: {} }
1056  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1057replication:
1058  mode: snapshot_then_cdc
1059  snapshot:
1060    source:
1061      type: postgres
1062      config: { connection_url: "${vars.base}", query: "SELECT 1" }
1063"#,
1064        );
1065        assert_eq!(
1066            cfg.replication.as_ref().unwrap().snapshot.source.config["connection_url"],
1067            "postgres://db/orders"
1068        );
1069    }
1070
1071    #[test]
1072    fn resolves_vars_referencing_other_vars() {
1073        let cfg = load(
1074            r#"
1075version: 1
1076vars:
1077  base: https://api.example.com
1078  users_url: "${vars.base}/v1/users"
1079pipeline:
1080  source: { type: rest, config: { url: "${vars.users_url}" } }
1081  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1082"#,
1083        );
1084        assert_eq!(
1085            cfg.pipeline.source.as_ref().unwrap().config["url"],
1086            "https://api.example.com/v1/users"
1087        );
1088    }
1089
1090    #[test]
1091    fn resolves_template_ref_from_matrix_row() {
1092        let cfg = load(
1093            r#"
1094version: 1
1095pipeline:
1096  sources:
1097    users_api:
1098      type: rest
1099      config: { base_url: https://api.example.com }
1100  sinks:
1101    archive: { type: jsonl, config: { path: ./out.jsonl } }
1102matrix:
1103  - id: load_users
1104    source:
1105      ref: users_api
1106      config: { audit_url: "${sources.users_api.config.base_url}/audit" }
1107"#,
1108        );
1109        let row_src = cfg.matrix[0].source.as_ref().unwrap();
1110        assert_eq!(
1111            row_src.config.as_ref().unwrap()["audit_url"],
1112            "https://api.example.com/audit"
1113        );
1114    }
1115
1116    #[test]
1117    fn detects_vars_cycle() {
1118        let yaml = r#"
1119version: 1
1120vars:
1121  a: "${vars.b}"
1122  b: "${vars.c}"
1123  c: "${vars.a}"
1124pipeline:
1125  source: { type: rest, config: {} }
1126  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1127"#;
1128        // resolve_config_refs now runs inside from_text; the error surfaces there.
1129        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1130        match err {
1131            CliError::InterpolationCycle { chain } => {
1132                // 3-node cycle a → b → c → a: chain should have 4 entries with
1133                // the first equal to the last (the closing back-edge).
1134                assert_eq!(chain.len(), 4, "chain: {chain:?}");
1135                assert_eq!(chain.first(), chain.last(), "chain: {chain:?}");
1136                // Every node appears exactly once interior, plus the closing edge.
1137                let mut sorted_interior: Vec<_> = chain[..3].to_vec();
1138                sorted_interior.sort();
1139                assert_eq!(sorted_interior, vec!["vars.a", "vars.b", "vars.c"]);
1140            }
1141            other => panic!("expected InterpolationCycle, got {other:?}"),
1142        }
1143    }
1144
1145    #[test]
1146    fn resolves_cross_template_reference() {
1147        // sources.b references sources.a via ${sources.a.config.host}.
1148        let cfg = load(
1149            r#"
1150version: 1
1151pipeline:
1152  sources:
1153    a: { type: rest, config: { host: api.example.com } }
1154    b: { type: rest, config: { host: "${sources.a.config.host}" } }
1155  sinks:
1156    out: { type: jsonl, config: { path: ./o.jsonl } }
1157"#,
1158        );
1159        assert_eq!(cfg.pipeline.sources["b"].config["host"], "api.example.com");
1160    }
1161
1162    #[test]
1163    fn resolves_chained_cross_template_reference() {
1164        // a → b → c, where c holds the literal. A single resolution pass (as
1165        // production runs via `from_text`) must follow the chain all the way
1166        // to the literal, not stop at b's token text (#78/#10). NB: we use
1167        // `parse_with_extension` directly (one pass) rather than the `load`
1168        // helper, which resolves twice and would mask a single-pass gap.
1169        let cfg = parse_with_extension(
1170            r#"
1171version: 1
1172pipeline:
1173  sources:
1174    a: { type: rest, config: { host: "${sources.b.config.host}" } }
1175    b: { type: rest, config: { host: "${sources.c.config.host}" } }
1176    c: { type: rest, config: { host: db.example.com } }
1177  sinks:
1178    out: { type: jsonl, config: { path: ./o.jsonl } }
1179"#,
1180            "yaml",
1181        )
1182        .unwrap();
1183        assert_eq!(cfg.pipeline.sources["a"].config["host"], "db.example.com");
1184        assert_eq!(cfg.pipeline.sources["b"].config["host"], "db.example.com");
1185    }
1186
1187    #[test]
1188    fn resolves_cross_template_reference_across_kinds() {
1189        // A sink template referencing a source template (cross-namespace).
1190        let cfg = load(
1191            r#"
1192version: 1
1193pipeline:
1194  sources:
1195    api: { type: rest, config: { host: api.example.com } }
1196  sinks:
1197    mirror: { type: http, config: { url: "${sources.api.config.host}" } }
1198"#,
1199        );
1200        assert_eq!(
1201            cfg.pipeline.sinks["mirror"].config["url"],
1202            "api.example.com"
1203        );
1204    }
1205
1206    #[test]
1207    fn detects_cross_template_cycle() {
1208        // a → b → a is a mutual cycle and must error, not silently leave each
1209        // template holding the other's literal token text (#78/#10).
1210        let yaml = r#"
1211version: 1
1212pipeline:
1213  sources:
1214    a: { type: rest, config: { host: "${sources.b.config.host}" } }
1215    b: { type: rest, config: { host: "${sources.a.config.host}" } }
1216  sinks:
1217    out: { type: jsonl, config: { path: ./o.jsonl } }
1218"#;
1219        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1220        match err {
1221            CliError::InterpolationCycle { chain } => {
1222                assert!(chain.first() == chain.last(), "chain: {chain:?}");
1223                assert!(
1224                    chain.iter().any(|c| c == "sources.a")
1225                        && chain.iter().any(|c| c == "sources.b"),
1226                    "chain must name both templates: {chain:?}"
1227                );
1228            }
1229            other => panic!("expected InterpolationCycle, got {other:?}"),
1230        }
1231    }
1232
1233    #[test]
1234    fn unknown_template_path_errors() {
1235        // sources.a exists, but its config has no `missing_field` path.
1236        let yaml = r#"
1237version: 1
1238pipeline:
1239  sources:
1240    a: { type: rest, config: { host: x } }
1241  source: { type: rest, config: { x: "${sources.a.config.missing_field}" } }
1242  sink: { type: jsonl, config: { path: ./o.jsonl } }
1243"#;
1244        // resolve_config_refs now runs inside from_text; the error surfaces there.
1245        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1246        match err {
1247            CliError::UnknownTemplateRef { reason, .. } => {
1248                assert!(reason.contains("missing_field"));
1249            }
1250            other => panic!("expected UnknownTemplateRef, got {other:?}"),
1251        }
1252    }
1253
1254    #[test]
1255    fn unknown_var_errors() {
1256        let yaml = r#"
1257version: 1
1258pipeline:
1259  source: { type: rest, config: { url: "${vars.nope}" } }
1260  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1261"#;
1262        // resolve_config_refs now runs inside from_text; the error surfaces there.
1263        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1264        match err {
1265            CliError::UnknownVarsRef { name, .. } => assert_eq!(name, "nope"),
1266            other => panic!("expected UnknownVarsRef, got {other:?}"),
1267        }
1268    }
1269
1270    #[test]
1271    fn unknown_template_ref_errors() {
1272        let yaml = r#"
1273version: 1
1274pipeline:
1275  source: { type: rest, config: { x: "${sources.nope.config.foo}" } }
1276  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1277"#;
1278        // resolve_config_refs now runs inside from_text; the error surfaces there.
1279        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1280        match err {
1281            CliError::UnknownTemplateRef { reason, .. } => {
1282                assert!(reason.to_ascii_lowercase().contains("nope"));
1283            }
1284            other => panic!("expected UnknownTemplateRef, got {other:?}"),
1285        }
1286    }
1287
1288    #[test]
1289    fn leaves_row_id_tokens_for_runtime() {
1290        let cfg = load(
1291            r#"
1292version: 1
1293pipeline:
1294  source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1295  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1296"#,
1297        );
1298        // ${users.id} must survive — it's a deferred row-id reference.
1299        assert_eq!(
1300            cfg.pipeline.source.as_ref().unwrap().config["path"],
1301            "/v1/users/${users.id}/posts"
1302        );
1303    }
1304
1305    #[test]
1306    fn resolves_vars_inside_auth_catalog() {
1307        // The shared `auth:` catalog must be a first-class config location:
1308        // ${vars.X} (and ${sources/sinks.X.PATH}) resolve there too (#134).
1309        let cfg = load(
1310            r#"
1311version: 1
1312vars:
1313  idp_token: topsecret
1314auth:
1315  idp: { type: static, config: { token: "Bearer ${vars.idp_token}" } }
1316pipeline:
1317  source: { type: rest, config: { base_url: https://x } }
1318  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1319"#,
1320        );
1321        assert_eq!(
1322            cfg.auth.as_ref().unwrap()["idp"]["config"]["token"],
1323            "Bearer topsecret"
1324        );
1325    }
1326
1327    // ── resolve_now tests ────────────────────────────────────────────────────
1328
1329    fn fixed_clock() -> chrono::DateTime<chrono::FixedOffset> {
1330        use chrono::TimeZone;
1331        // 2026-03-08 14:05:09 +00:00
1332        chrono::FixedOffset::east_opt(0)
1333            .unwrap()
1334            .with_ymd_and_hms(2026, 3, 8, 14, 5, 9)
1335            .unwrap()
1336    }
1337
1338    #[test]
1339    fn now_named_tokens_render() {
1340        let c = fixed_clock();
1341        assert_eq!(resolve_now("${now.date}", c).unwrap(), "2026-03-08");
1342        assert_eq!(resolve_now("${now.year}", c).unwrap(), "2026");
1343        assert_eq!(resolve_now("${now.month}", c).unwrap(), "03");
1344        assert_eq!(resolve_now("${now.day}", c).unwrap(), "08");
1345        assert_eq!(resolve_now("${now.hour}", c).unwrap(), "14");
1346        assert_eq!(resolve_now("${now.minute}", c).unwrap(), "05");
1347        assert_eq!(resolve_now("${now.second}", c).unwrap(), "09");
1348        assert_eq!(
1349            resolve_now("${now.unix}", c).unwrap(),
1350            c.timestamp().to_string()
1351        );
1352        assert!(
1353            resolve_now("${now.iso}", c)
1354                .unwrap()
1355                .starts_with("2026-03-08T14:05:09")
1356        );
1357        assert_eq!(
1358            resolve_now("${now.datetime}", c).unwrap(),
1359            resolve_now("${now.iso}", c).unwrap()
1360        );
1361    }
1362
1363    #[test]
1364    fn now_in_a_path_template() {
1365        let c = fixed_clock();
1366        assert_eq!(
1367            resolve_now("s3://bucket/dt=${now.date}/part.jsonl", c).unwrap(),
1368            "s3://bucket/dt=2026-03-08/part.jsonl"
1369        );
1370    }
1371
1372    #[test]
1373    fn now_strftime_renders_and_rejects_bad_format() {
1374        let c = fixed_clock();
1375        assert_eq!(
1376            resolve_now("${now.strftime.%Y/%m/%d}", c).unwrap(),
1377            "2026/03/08"
1378        );
1379        // A bogus specifier must be a clean config error, NOT a panic.
1380        // `%Q` is not a valid strftime specifier in chrono and produces Item::Error.
1381        let err = resolve_now("${now.strftime.%Q}", c).unwrap_err();
1382        assert!(err.to_string().contains("strftime"));
1383    }
1384
1385    #[test]
1386    fn now_unknown_token_errors() {
1387        let c = fixed_clock();
1388        let err = resolve_now("${now.bogus}", c).unwrap_err();
1389        assert!(err.to_string().contains("now.bogus"));
1390    }
1391
1392    #[test]
1393    fn now_leaves_other_tokens_verbatim() {
1394        let c = fixed_clock();
1395        // env/row-id/vars tokens must survive the now-pass untouched.
1396        assert_eq!(
1397            resolve_now("${env:VAR}/${users.id}/${now.date}", c).unwrap(),
1398            "${env:VAR}/${users.id}/2026-03-08"
1399        );
1400    }
1401
1402    #[test]
1403    fn resolves_lineage_job_name_tokens() {
1404        assert_eq!(
1405            resolve_lineage_job_name("${name}::${row_id}", "orders", "users"),
1406            "orders::users"
1407        );
1408        assert_eq!(
1409            resolve_lineage_job_name("static", "orders", "users"),
1410            "static"
1411        );
1412    }
1413}