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#[cfg(test)]
682mod tests {
683    use super::*;
684    use serde_json::json;
685    use std::collections::HashMap;
686
687    #[test]
688    fn passes_through_text_with_no_directives() {
689        let out = interpolate("just a string").unwrap();
690        assert_eq!(out, "just a string");
691    }
692
693    #[test]
694    fn substitutes_env_var() {
695        unsafe { std::env::set_var("FAUCET_TEST_VAR", "hello") };
696        let out = interpolate("token=${env:FAUCET_TEST_VAR}").unwrap();
697        assert_eq!(out, "token=hello");
698        unsafe { std::env::remove_var("FAUCET_TEST_VAR") };
699    }
700
701    #[test]
702    fn missing_env_var_is_an_error() {
703        unsafe { std::env::remove_var("FAUCET_TEST_MISSING") };
704        let err = interpolate("token=${env:FAUCET_TEST_MISSING}").unwrap_err();
705        match err {
706            CliError::MissingEnvVar { var, .. } => assert_eq!(var, "FAUCET_TEST_MISSING"),
707            other => panic!("expected MissingEnvVar, got {other:?}"),
708        }
709    }
710
711    #[test]
712    fn secret_prefix_is_env_alias_for_now() {
713        unsafe { std::env::set_var("FAUCET_SECRET_VAR", "shh") };
714        let out = interpolate("${secret:FAUCET_SECRET_VAR}").unwrap();
715        assert_eq!(out, "shh");
716        unsafe { std::env::remove_var("FAUCET_SECRET_VAR") };
717    }
718
719    #[test]
720    fn resolved_env_and_secret_values_are_registered_for_redaction() {
721        // M3 (#146): credentials supplied via ${env:}/${secret:} must be
722        // scrubbed from faucet's tracing/log/error output, just like
723        // ${vault:…} values — not left to leak.
724        let secret = "super-secret-token-abcdef-1234567890"; // >= MIN_REDACT_LEN
725        unsafe { std::env::set_var("FAUCET_M3_REDACT_TOKEN", secret) };
726        let out = interpolate("Authorization: Bearer ${env:FAUCET_M3_REDACT_TOKEN}").unwrap();
727        assert!(out.contains(secret));
728        let redacted = crate::secrets::registry::redact(&out);
729        assert!(
730            !redacted.contains(secret),
731            "resolved ${{env:}} value must be registered for redaction"
732        );
733        assert!(redacted.contains("***"));
734        unsafe { std::env::remove_var("FAUCET_M3_REDACT_TOKEN") };
735    }
736
737    #[test]
738    fn reads_file_directive_and_trims_trailing_newline() {
739        let dir = tempfile::tempdir().unwrap();
740        let path = dir.path().join("token.txt");
741        std::fs::write(&path, "abcdef\n").unwrap();
742        let raw = format!("token=${{file:{}}}", path.display());
743        let out = interpolate(&raw).unwrap();
744        assert_eq!(out, "token=abcdef");
745    }
746
747    #[test]
748    fn file_directive_rejects_oversized_file() {
749        // Regression for #78/#37: a file larger than the cap errors instead of
750        // being read unboundedly into memory.
751        let dir = tempfile::tempdir().unwrap();
752        let path = dir.path().join("big.bin");
753        let big = vec![b'x'; (MAX_INTERPOLATED_FILE_BYTES + 10) as usize];
754        std::fs::write(&path, &big).unwrap();
755        let raw = format!("${{file:{}}}", path.display());
756        match interpolate(&raw).unwrap_err() {
757            CliError::InterpolatedFileTooLarge { max_bytes, .. } => {
758                assert_eq!(max_bytes, MAX_INTERPOLATED_FILE_BYTES);
759            }
760            other => panic!("expected InterpolatedFileTooLarge, got {other:?}"),
761        }
762    }
763
764    #[test]
765    fn file_directive_reads_file_at_the_limit() {
766        // Exactly at the cap is allowed.
767        let dir = tempfile::tempdir().unwrap();
768        let path = dir.path().join("ok.bin");
769        std::fs::write(&path, vec![b'a'; MAX_INTERPOLATED_FILE_BYTES as usize]).unwrap();
770        let raw = format!("${{file:{}}}", path.display());
771        assert!(interpolate(&raw).is_ok());
772    }
773
774    #[test]
775    fn load_time_leaves_id_path_tokens_alone() {
776        unsafe { std::env::set_var("FAUCET_T", "v") };
777        let out = interpolate("a=${env:FAUCET_T} b=${users.id}").unwrap();
778        assert_eq!(out, "a=v b=${users.id}");
779        unsafe { std::env::remove_var("FAUCET_T") };
780    }
781
782    #[test]
783    fn load_time_passes_unknown_prefix_through() {
784        // Unknown prefixes are deferred — the matrix expander decides if
785        // they reference a real row id later. (Pre-#54 behaviour was to error
786        // here; that responsibility moves to expand.rs.)
787        let out = interpolate("${weird:thing}").unwrap();
788        assert_eq!(out, "${weird:thing}");
789    }
790
791    #[test]
792    fn classify_colon_is_load_time_dot_is_deferred() {
793        // The single classification rule shared by interpolate + expand (#78/#39).
794        assert!(matches!(
795            classify_directive("env:VAR"),
796            Directive::LoadTime {
797                prefix: "env",
798                body: "VAR"
799            }
800        ));
801        // No colon → deferred, even for a reserved-looking prefix like `env`.
802        assert!(matches!(
803            classify_directive("env.foo"),
804            Directive::Deferred {
805                id: "env",
806                path: "foo"
807            }
808        ));
809        assert!(matches!(
810            classify_directive("users.addr.city"),
811            Directive::Deferred {
812                id: "users",
813                path: "addr.city"
814            }
815        ));
816        assert!(matches!(
817            classify_directive("row"),
818            Directive::Deferred {
819                id: "row",
820                path: ""
821            }
822        ));
823    }
824
825    #[test]
826    fn iter_directives_finds_tokens_and_skips_escapes() {
827        let toks: Vec<_> = iter_directives("a=${env:V} b=${users.id} c=$${lit}").collect();
828        // The escaped $${lit} is not a token.
829        assert_eq!(toks.len(), 2);
830        assert_eq!(toks[0].0, "${env:V}");
831        assert!(matches!(
832            toks[0].1,
833            Directive::LoadTime { prefix: "env", .. }
834        ));
835        assert_eq!(toks[1].0, "${users.id}");
836        assert!(matches!(toks[1].1, Directive::Deferred { id: "users", .. }));
837    }
838
839    #[test]
840    fn dollar_dollar_brace_is_escaped() {
841        let out = interpolate("path=$${env:VAR}").unwrap();
842        assert_eq!(out, "path=${env:VAR}");
843    }
844
845    #[test]
846    fn unclosed_directive_is_left_literal() {
847        let out = interpolate("hello ${env:NOPE").unwrap();
848        assert_eq!(out, "hello ${env:NOPE");
849    }
850
851    #[test]
852    fn multiple_directives_resolve_in_order() {
853        unsafe { std::env::set_var("FAUCET_A", "one") };
854        unsafe { std::env::set_var("FAUCET_B", "two") };
855        let out = interpolate("${env:FAUCET_A}-${env:FAUCET_B}").unwrap();
856        assert_eq!(out, "one-two");
857        unsafe { std::env::remove_var("FAUCET_A") };
858        unsafe { std::env::remove_var("FAUCET_B") };
859    }
860
861    // ── record-time tests ───────────────────────────────────────────────
862
863    fn ctx_with(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
864        pairs
865            .iter()
866            .map(|(k, v)| ((*k).into(), v.clone()))
867            .collect()
868    }
869
870    #[test]
871    fn record_resolves_simple_dotted_path() {
872        let ctx = ctx_with(&[("users", json!({"id": 42, "name": "alice"}))]);
873        let out = interpolate_record("/v1/users/${users.id}", &ctx).unwrap();
874        assert_eq!(out, "/v1/users/42");
875    }
876
877    #[test]
878    fn record_resolves_nested_dotted_path() {
879        let ctx = ctx_with(&[(
880            "users",
881            json!({"id": 1, "addr": {"city": "NYC", "zip": "10001"}}),
882        )]);
883        let out = interpolate_record("/${users.addr.city}/${users.addr.zip}", &ctx).unwrap();
884        assert_eq!(out, "/NYC/10001");
885    }
886
887    #[test]
888    fn record_resolves_array_index() {
889        let ctx = ctx_with(&[("users", json!({"tags": ["a", "b", "c"]}))]);
890        let out = interpolate_record("first=${users.tags.0}", &ctx).unwrap();
891        assert_eq!(out, "first=a");
892    }
893
894    #[test]
895    fn record_renders_numbers_and_booleans_as_strings() {
896        let ctx = ctx_with(&[("users", json!({"id": 7, "active": true}))]);
897        let out = interpolate_record("id=${users.id} active=${users.active}", &ctx).unwrap();
898        assert_eq!(out, "id=7 active=true");
899    }
900
901    #[test]
902    fn record_unknown_id_errors() {
903        let ctx = ctx_with(&[("users", json!({"id": 1}))]);
904        let err = interpolate_record("${nobody.x}", &ctx).unwrap_err();
905        assert!(matches!(err, CliError::UnknownInterpolationId { .. }));
906    }
907
908    #[test]
909    fn record_missing_field_errors() {
910        let ctx = ctx_with(&[("users", json!({"id": 1}))]);
911        let err = interpolate_record("${users.missing}", &ctx).unwrap_err();
912        match err {
913            CliError::MissingRecordField { id, path } => {
914                assert_eq!(id, "users");
915                assert_eq!(path, "missing");
916            }
917            other => panic!("expected MissingRecordField, got {other:?}"),
918        }
919    }
920
921    #[test]
922    fn record_leaves_load_time_directives_alone() {
923        let ctx = HashMap::new();
924        let out = interpolate_record("a=${env:NOPE} b=${file:./x}", &ctx).unwrap();
925        assert_eq!(out, "a=${env:NOPE} b=${file:./x}");
926    }
927
928    // ── post-parse load-time tests ───────────────────────────────────────
929
930    use crate::config::{PipelineConfig, parse_with_extension};
931    use crate::interpolate::resolve_config_refs;
932
933    fn load(yaml: &str) -> PipelineConfig {
934        let mut cfg = parse_with_extension(yaml, "yaml").unwrap();
935        resolve_config_refs(&mut cfg).unwrap();
936        cfg
937    }
938
939    #[test]
940    fn resolves_vars_in_source_config() {
941        let cfg = load(
942            r#"
943version: 1
944vars:
945  base: https://api.example.com
946pipeline:
947  source: { type: rest, config: { base_url: "${vars.base}" } }
948  sink:   { type: jsonl, config: { path: ./o.jsonl } }
949"#,
950        );
951        assert_eq!(
952            cfg.pipeline.source.as_ref().unwrap().config["base_url"],
953            "https://api.example.com"
954        );
955    }
956
957    #[test]
958    fn resolves_vars_referencing_other_vars() {
959        let cfg = load(
960            r#"
961version: 1
962vars:
963  base: https://api.example.com
964  users_url: "${vars.base}/v1/users"
965pipeline:
966  source: { type: rest, config: { url: "${vars.users_url}" } }
967  sink:   { type: jsonl, config: { path: ./o.jsonl } }
968"#,
969        );
970        assert_eq!(
971            cfg.pipeline.source.as_ref().unwrap().config["url"],
972            "https://api.example.com/v1/users"
973        );
974    }
975
976    #[test]
977    fn resolves_template_ref_from_matrix_row() {
978        let cfg = load(
979            r#"
980version: 1
981pipeline:
982  sources:
983    users_api:
984      type: rest
985      config: { base_url: https://api.example.com }
986  sinks:
987    archive: { type: jsonl, config: { path: ./out.jsonl } }
988matrix:
989  - id: load_users
990    source:
991      ref: users_api
992      config: { audit_url: "${sources.users_api.config.base_url}/audit" }
993"#,
994        );
995        let row_src = cfg.matrix[0].source.as_ref().unwrap();
996        assert_eq!(
997            row_src.config.as_ref().unwrap()["audit_url"],
998            "https://api.example.com/audit"
999        );
1000    }
1001
1002    #[test]
1003    fn detects_vars_cycle() {
1004        let yaml = r#"
1005version: 1
1006vars:
1007  a: "${vars.b}"
1008  b: "${vars.c}"
1009  c: "${vars.a}"
1010pipeline:
1011  source: { type: rest, config: {} }
1012  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1013"#;
1014        // resolve_config_refs now runs inside from_text; the error surfaces there.
1015        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1016        match err {
1017            CliError::InterpolationCycle { chain } => {
1018                // 3-node cycle a → b → c → a: chain should have 4 entries with
1019                // the first equal to the last (the closing back-edge).
1020                assert_eq!(chain.len(), 4, "chain: {chain:?}");
1021                assert_eq!(chain.first(), chain.last(), "chain: {chain:?}");
1022                // Every node appears exactly once interior, plus the closing edge.
1023                let mut sorted_interior: Vec<_> = chain[..3].to_vec();
1024                sorted_interior.sort();
1025                assert_eq!(sorted_interior, vec!["vars.a", "vars.b", "vars.c"]);
1026            }
1027            other => panic!("expected InterpolationCycle, got {other:?}"),
1028        }
1029    }
1030
1031    #[test]
1032    fn resolves_cross_template_reference() {
1033        // sources.b references sources.a via ${sources.a.config.host}.
1034        let cfg = load(
1035            r#"
1036version: 1
1037pipeline:
1038  sources:
1039    a: { type: rest, config: { host: api.example.com } }
1040    b: { type: rest, config: { host: "${sources.a.config.host}" } }
1041  sinks:
1042    out: { type: jsonl, config: { path: ./o.jsonl } }
1043"#,
1044        );
1045        assert_eq!(cfg.pipeline.sources["b"].config["host"], "api.example.com");
1046    }
1047
1048    #[test]
1049    fn resolves_chained_cross_template_reference() {
1050        // a → b → c, where c holds the literal. A single resolution pass (as
1051        // production runs via `from_text`) must follow the chain all the way
1052        // to the literal, not stop at b's token text (#78/#10). NB: we use
1053        // `parse_with_extension` directly (one pass) rather than the `load`
1054        // helper, which resolves twice and would mask a single-pass gap.
1055        let cfg = parse_with_extension(
1056            r#"
1057version: 1
1058pipeline:
1059  sources:
1060    a: { type: rest, config: { host: "${sources.b.config.host}" } }
1061    b: { type: rest, config: { host: "${sources.c.config.host}" } }
1062    c: { type: rest, config: { host: db.example.com } }
1063  sinks:
1064    out: { type: jsonl, config: { path: ./o.jsonl } }
1065"#,
1066            "yaml",
1067        )
1068        .unwrap();
1069        assert_eq!(cfg.pipeline.sources["a"].config["host"], "db.example.com");
1070        assert_eq!(cfg.pipeline.sources["b"].config["host"], "db.example.com");
1071    }
1072
1073    #[test]
1074    fn resolves_cross_template_reference_across_kinds() {
1075        // A sink template referencing a source template (cross-namespace).
1076        let cfg = load(
1077            r#"
1078version: 1
1079pipeline:
1080  sources:
1081    api: { type: rest, config: { host: api.example.com } }
1082  sinks:
1083    mirror: { type: http, config: { url: "${sources.api.config.host}" } }
1084"#,
1085        );
1086        assert_eq!(
1087            cfg.pipeline.sinks["mirror"].config["url"],
1088            "api.example.com"
1089        );
1090    }
1091
1092    #[test]
1093    fn detects_cross_template_cycle() {
1094        // a → b → a is a mutual cycle and must error, not silently leave each
1095        // template holding the other's literal token text (#78/#10).
1096        let yaml = r#"
1097version: 1
1098pipeline:
1099  sources:
1100    a: { type: rest, config: { host: "${sources.b.config.host}" } }
1101    b: { type: rest, config: { host: "${sources.a.config.host}" } }
1102  sinks:
1103    out: { type: jsonl, config: { path: ./o.jsonl } }
1104"#;
1105        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1106        match err {
1107            CliError::InterpolationCycle { chain } => {
1108                assert!(chain.first() == chain.last(), "chain: {chain:?}");
1109                assert!(
1110                    chain.iter().any(|c| c == "sources.a")
1111                        && chain.iter().any(|c| c == "sources.b"),
1112                    "chain must name both templates: {chain:?}"
1113                );
1114            }
1115            other => panic!("expected InterpolationCycle, got {other:?}"),
1116        }
1117    }
1118
1119    #[test]
1120    fn unknown_template_path_errors() {
1121        // sources.a exists, but its config has no `missing_field` path.
1122        let yaml = r#"
1123version: 1
1124pipeline:
1125  sources:
1126    a: { type: rest, config: { host: x } }
1127  source: { type: rest, config: { x: "${sources.a.config.missing_field}" } }
1128  sink: { type: jsonl, config: { path: ./o.jsonl } }
1129"#;
1130        // resolve_config_refs now runs inside from_text; the error surfaces there.
1131        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1132        match err {
1133            CliError::UnknownTemplateRef { reason, .. } => {
1134                assert!(reason.contains("missing_field"));
1135            }
1136            other => panic!("expected UnknownTemplateRef, got {other:?}"),
1137        }
1138    }
1139
1140    #[test]
1141    fn unknown_var_errors() {
1142        let yaml = r#"
1143version: 1
1144pipeline:
1145  source: { type: rest, config: { url: "${vars.nope}" } }
1146  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1147"#;
1148        // resolve_config_refs now runs inside from_text; the error surfaces there.
1149        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1150        match err {
1151            CliError::UnknownVarsRef { name, .. } => assert_eq!(name, "nope"),
1152            other => panic!("expected UnknownVarsRef, got {other:?}"),
1153        }
1154    }
1155
1156    #[test]
1157    fn unknown_template_ref_errors() {
1158        let yaml = r#"
1159version: 1
1160pipeline:
1161  source: { type: rest, config: { x: "${sources.nope.config.foo}" } }
1162  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1163"#;
1164        // resolve_config_refs now runs inside from_text; the error surfaces there.
1165        let err = parse_with_extension(yaml, "yaml").unwrap_err();
1166        match err {
1167            CliError::UnknownTemplateRef { reason, .. } => {
1168                assert!(reason.to_ascii_lowercase().contains("nope"));
1169            }
1170            other => panic!("expected UnknownTemplateRef, got {other:?}"),
1171        }
1172    }
1173
1174    #[test]
1175    fn leaves_row_id_tokens_for_runtime() {
1176        let cfg = load(
1177            r#"
1178version: 1
1179pipeline:
1180  source: { type: rest, config: { path: "/v1/users/${users.id}/posts" } }
1181  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1182"#,
1183        );
1184        // ${users.id} must survive — it's a deferred row-id reference.
1185        assert_eq!(
1186            cfg.pipeline.source.as_ref().unwrap().config["path"],
1187            "/v1/users/${users.id}/posts"
1188        );
1189    }
1190
1191    #[test]
1192    fn resolves_vars_inside_auth_catalog() {
1193        // The shared `auth:` catalog must be a first-class config location:
1194        // ${vars.X} (and ${sources/sinks.X.PATH}) resolve there too (#134).
1195        let cfg = load(
1196            r#"
1197version: 1
1198vars:
1199  idp_token: topsecret
1200auth:
1201  idp: { type: static, config: { token: "Bearer ${vars.idp_token}" } }
1202pipeline:
1203  source: { type: rest, config: { base_url: https://x } }
1204  sink:   { type: jsonl, config: { path: ./o.jsonl } }
1205"#,
1206        );
1207        assert_eq!(
1208            cfg.auth.as_ref().unwrap()["idp"]["config"]["token"],
1209            "Bearer topsecret"
1210        );
1211    }
1212
1213    // ── resolve_now tests ────────────────────────────────────────────────────
1214
1215    fn fixed_clock() -> chrono::DateTime<chrono::FixedOffset> {
1216        use chrono::TimeZone;
1217        // 2026-03-08 14:05:09 +00:00
1218        chrono::FixedOffset::east_opt(0)
1219            .unwrap()
1220            .with_ymd_and_hms(2026, 3, 8, 14, 5, 9)
1221            .unwrap()
1222    }
1223
1224    #[test]
1225    fn now_named_tokens_render() {
1226        let c = fixed_clock();
1227        assert_eq!(resolve_now("${now.date}", c).unwrap(), "2026-03-08");
1228        assert_eq!(resolve_now("${now.year}", c).unwrap(), "2026");
1229        assert_eq!(resolve_now("${now.month}", c).unwrap(), "03");
1230        assert_eq!(resolve_now("${now.day}", c).unwrap(), "08");
1231        assert_eq!(resolve_now("${now.hour}", c).unwrap(), "14");
1232        assert_eq!(resolve_now("${now.minute}", c).unwrap(), "05");
1233        assert_eq!(resolve_now("${now.second}", c).unwrap(), "09");
1234        assert_eq!(
1235            resolve_now("${now.unix}", c).unwrap(),
1236            c.timestamp().to_string()
1237        );
1238        assert!(
1239            resolve_now("${now.iso}", c)
1240                .unwrap()
1241                .starts_with("2026-03-08T14:05:09")
1242        );
1243        assert_eq!(
1244            resolve_now("${now.datetime}", c).unwrap(),
1245            resolve_now("${now.iso}", c).unwrap()
1246        );
1247    }
1248
1249    #[test]
1250    fn now_in_a_path_template() {
1251        let c = fixed_clock();
1252        assert_eq!(
1253            resolve_now("s3://bucket/dt=${now.date}/part.jsonl", c).unwrap(),
1254            "s3://bucket/dt=2026-03-08/part.jsonl"
1255        );
1256    }
1257
1258    #[test]
1259    fn now_strftime_renders_and_rejects_bad_format() {
1260        let c = fixed_clock();
1261        assert_eq!(
1262            resolve_now("${now.strftime.%Y/%m/%d}", c).unwrap(),
1263            "2026/03/08"
1264        );
1265        // A bogus specifier must be a clean config error, NOT a panic.
1266        // `%Q` is not a valid strftime specifier in chrono and produces Item::Error.
1267        let err = resolve_now("${now.strftime.%Q}", c).unwrap_err();
1268        assert!(err.to_string().contains("strftime"));
1269    }
1270
1271    #[test]
1272    fn now_unknown_token_errors() {
1273        let c = fixed_clock();
1274        let err = resolve_now("${now.bogus}", c).unwrap_err();
1275        assert!(err.to_string().contains("now.bogus"));
1276    }
1277
1278    #[test]
1279    fn now_leaves_other_tokens_verbatim() {
1280        let c = fixed_clock();
1281        // env/row-id/vars tokens must survive the now-pass untouched.
1282        assert_eq!(
1283            resolve_now("${env:VAR}/${users.id}/${now.date}", c).unwrap(),
1284            "${env:VAR}/${users.id}/2026-03-08"
1285        );
1286    }
1287}