Skip to main content

faucet_cli/
interpolate.rs

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