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