Skip to main content

proef_core/
resolve.rs

1//! Author-time `${…}` variable resolution (ADR-0005, TECH-SPEC §8).
2//!
3//! `${…}` is the **author-time** tier, resolved during lowering — recursively
4//! (substituted values may themselves contain `${…}`, spike-verified), with a
5//! depth cap of [`MAX_DEPTH`]. `{{…}}` is hurl's **run-time** tier and passes
6//! through untouched. `$${` escapes to a literal `${` (applied after the final
7//! pass, so escaped text is never re-resolved).
8//!
9//! Reference forms: `${param}` (scope lookup: step args > macro defaults) ·
10//! `${env:NAME}` / `${env:NAME:-default}`
11//! (injected snapshot — core reads no environment) · `${run:id}` (injected) ·
12//! `${global:key}` (World read at lower time) · `${secret:NAME}` (emits the
13//! `{{NAME}}` run-time placeholder and records the name — values never enter
14//! lowered text) · `${fake:kind}` (deterministic synthetic data).
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use crate::world::World;
19
20/// Maximum resolution passes before assuming a reference cycle (TECH-SPEC §4.4).
21pub const MAX_DEPTH: usize = 8;
22
23/// How strictly to treat values that only exist at run time.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ResolveMode {
26    /// Execution: a missing `${global:key}` is an error.
27    Strict,
28    /// `--dry-run`: runtime-populated globals cannot be known — substitute an
29    /// empty string and record a warning instead of failing.
30    DryRun,
31    /// Pack-load probe instantiation (validation pass 7): any reference that
32    /// merely *might* resolve later (unknown vars, env, globals, fakes)
33    /// substitutes the placeholder `probe` — only statically-wrong syntax
34    /// (empty reference, unknown namespace, unknown run field) still errors.
35    Probe,
36}
37
38/// Everything a resolution pass may read. All values are injected — resolution
39/// itself is pure (core purity).
40#[derive(Debug, Clone, Copy)]
41pub struct ResolveCtx<'a> {
42    /// Step arguments (captures + data table + `with:`), highest precedence.
43    pub args: &'a BTreeMap<String, String>,
44    /// Macro `defaults:`.
45    pub defaults: &'a BTreeMap<String, String>,
46    /// Injected environment snapshot.
47    pub env: &'a BTreeMap<String, String>,
48    /// Injected `proef.toml` config scope (`${url:key}`, `${vars:key}`), keyed
49    /// `"<namespace>:<key>"` — the CLI deep-merges the active `[env.<name>]` over
50    /// the base tables before injecting, so the core reads no file itself.
51    pub config_vars: &'a BTreeMap<String, String>,
52    /// Injected run identifier (`${run:id}`).
53    pub run_id: &'a str,
54    /// World, for `${global:key}` reads at lower time.
55    pub world: &'a World,
56    /// Strict (execution) or dry-run behavior.
57    pub mode: ResolveMode,
58}
59
60/// A successful resolution: the final text plus what it referenced.
61#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Resolution {
63    /// The resolved text (`{{…}}` untouched, escapes applied).
64    pub text: String,
65    /// Secret names referenced via `${secret:NAME}` (values never appear).
66    pub secrets: BTreeSet<String>,
67    /// Global keys read via `${global:key}` (drives `.vars` emission, ADR-0010).
68    pub globals: BTreeSet<String>,
69    /// `${fake:*}` values generated so far (occurrence indexing).
70    pub fakes: usize,
71    /// Dry-run soft findings (e.g. a runtime-only global).
72    pub warnings: Vec<String>,
73}
74
75/// Why a `${…}` reference failed to resolve. Codes are stable diagnostic identifiers.
76#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
77pub enum ResolveError {
78    /// A plain `${name}` found in no scope.
79    #[error("unknown variable `${{{name}}}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
80    UnknownVariable {
81        /// The unresolved name.
82        name: String,
83        /// Closest known name, when one is near.
84        suggestion: Option<String>,
85    },
86    /// `${env:NAME}` without a default, and NAME is not in the snapshot.
87    #[error(
88        "environment variable `{name}` is not set (use `${{env:{name}:-default}}` for a fallback)"
89    )]
90    MissingEnv {
91        /// The missing environment variable.
92        name: String,
93    },
94    /// `${global:key}` missing from the World (strict mode only).
95    #[error("global `{key}` is not set in the World")]
96    MissingGlobal {
97        /// The missing global key.
98        key: String,
99    },
100    /// `${url:key}` / `${vars:key}` referencing a value defined in neither the
101    /// base `proef.toml` table nor the active `[env.<name>]` profile.
102    #[error(
103        "{namespace} variable `{key}` is not set — define `[{namespace}]` `{key}` in proef.toml (or in the active `[env.<name>.{namespace}]`)"
104    )]
105    MissingConfigVar {
106        /// The namespace as written (`url` or `vars`).
107        namespace: String,
108        /// The referenced key.
109        key: String,
110    },
111    /// `${ns:…}` with an unrecognized namespace.
112    #[error(
113        "unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake, url, vars)"
114    )]
115    UnknownNamespace {
116        /// The namespace as written.
117        namespace: String,
118    },
119    /// `${run:…}` with something other than `id`.
120    #[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
121    UnknownRunField {
122        /// The field as written.
123        field: String,
124    },
125    /// `${fake:…}` names no known generator (statically rejected).
126    #[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
127    FakeUnknown {
128        /// The requested generator kind.
129        kind: String,
130        /// Closest known generator, when one is near.
131        suggestion: Option<String>,
132    },
133    /// An empty reference `${}`.
134    #[error("empty variable reference `${{}}`")]
135    EmptyReference,
136    /// Still-unresolved `${…}` after [`MAX_DEPTH`] passes — a reference cycle.
137    #[error(
138        "variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
139    )]
140    DepthExceeded {
141        /// A variable still unresolved when the cap was hit.
142        name: String,
143    },
144}
145
146impl ResolveError {
147    /// The stable diagnostic code for this failure.
148    pub fn code(&self) -> &'static str {
149        match self {
150            Self::UnknownVariable { .. } => "proef::resolve::unknown_variable",
151            Self::MissingEnv { .. } => "proef::resolve::missing_env",
152            Self::MissingConfigVar { .. } => "proef::resolve::missing_config_var",
153            Self::MissingGlobal { .. } => "proef::resolve::missing_global",
154            Self::UnknownNamespace { .. } => "proef::resolve::unknown_namespace",
155            Self::UnknownRunField { .. } => "proef::resolve::unknown_run_field",
156            Self::FakeUnknown { .. } => "proef::resolve::fake_unknown",
157            Self::EmptyReference => "proef::resolve::empty_reference",
158            Self::DepthExceeded { .. } => "proef::resolve::depth_exceeded",
159        }
160    }
161}
162
163/// Resolve every `${…}` in `text` (recursively, ≤ [`MAX_DEPTH`] passes), leave
164/// `{{…}}` untouched, then apply `$${` escapes. Pure and total.
165pub fn resolve(text: &str, ctx: &ResolveCtx<'_>) -> Result<Resolution, ResolveError> {
166    let mut resolution = Resolution::default();
167    let mut current = text.to_owned();
168
169    for _ in 0..MAX_DEPTH {
170        let (next, substituted) = resolve_pass(&current, ctx, &mut resolution)?;
171        current = next;
172        if !substituted {
173            resolution.text = unescape(&current);
174            return Ok(resolution);
175        }
176    }
177
178    if let Some((name, _, _)) = first_reference(&current) {
179        Err(ResolveError::DepthExceeded {
180            name: name.to_owned(),
181        })
182    } else {
183        resolution.text = unescape(&current);
184        Ok(resolution)
185    }
186}
187
188/// One left-to-right substitution pass. Returns the new text and whether any
189/// reference was substituted.
190fn resolve_pass(
191    text: &str,
192    ctx: &ResolveCtx<'_>,
193    resolution: &mut Resolution,
194) -> Result<(String, bool), ResolveError> {
195    let mut out = String::with_capacity(text.len());
196    let mut rest = text;
197    let mut substituted = false;
198
199    while let Some((name, start, end)) = first_reference(rest) {
200        out.push_str(&rest[..start]);
201        let value = lookup(name, ctx, resolution)?;
202        out.push_str(&value);
203        substituted = true;
204        rest = &rest[end..];
205    }
206    out.push_str(rest);
207    Ok((out, substituted))
208}
209
210/// Find the first live `${…}` reference, skipping `$${` escapes. Returns
211/// `(name, start_of_ref, end_after_brace)` in byte offsets.
212fn first_reference(text: &str) -> Option<(&str, usize, usize)> {
213    let bytes = text.as_bytes();
214    let mut i = 0;
215    while i < bytes.len() {
216        if bytes[i] == b'$' {
217            // `$${` — escaped: skip the whole escape marker.
218            if text[i..].starts_with("$${") {
219                i += 3;
220                continue;
221            }
222            if text[i..].starts_with("${") {
223                let after = &text[i + 2..];
224                if let Some(close) = after.find('}') {
225                    let name = &after[..close];
226                    return Some((name, i, i + 2 + close + 1));
227                }
228                // Unclosed `${` — treat as literal text.
229                return None;
230            }
231        }
232        i += 1;
233    }
234    None
235}
236
237/// Resolve one reference name to its substitution value.
238fn lookup(
239    name: &str,
240    ctx: &ResolveCtx<'_>,
241    resolution: &mut Resolution,
242) -> Result<String, ResolveError> {
243    let name = name.trim();
244    if name.is_empty() {
245        return Err(ResolveError::EmptyReference);
246    }
247
248    if let Some((namespace, arg)) = name.split_once(':') {
249        return match namespace {
250            "env" => {
251                let (var, default) = match arg.split_once(":-") {
252                    Some((var, default)) => (var, Some(default)),
253                    None => (arg, None),
254                };
255                match ctx.env.get(var) {
256                    Some(value) => Ok(value.clone()),
257                    None => match default {
258                        Some(default) => Ok(default.to_owned()),
259                        None => probe_or(
260                            ResolveError::MissingEnv {
261                                name: var.to_owned(),
262                            },
263                            ctx.mode,
264                        ),
265                    },
266                }
267            }
268            "run" => {
269                if arg == "id" {
270                    Ok(ctx.run_id.to_owned())
271                } else {
272                    Err(ResolveError::UnknownRunField {
273                        field: arg.to_owned(),
274                    })
275                }
276            }
277            "global" => {
278                resolution.globals.insert(arg.to_owned());
279                match ctx.world.get(arg) {
280                    Some(value) => Ok(value.to_string()),
281                    None => match ctx.mode {
282                        ResolveMode::Strict => Err(ResolveError::MissingGlobal {
283                            key: arg.to_owned(),
284                        }),
285                        ResolveMode::DryRun => {
286                            resolution.warnings.push(format!(
287                                "`${{global:{arg}}}` is not set yet — it may be populated at run time"
288                            ));
289                            Ok(String::new())
290                        }
291                        ResolveMode::Probe => Ok("probe".to_owned()),
292                    },
293                }
294            }
295            "secret" => {
296                resolution.secrets.insert(arg.to_owned());
297                // The run-time placeholder; the engine injects the value via
298                // `insert_secret` at run time — lowered text never carries it.
299                Ok(format!("{{{{{arg}}}}}"))
300            }
301            "fake" => {
302                // Unknown generators are statically wrong — they error in every
303                // mode (incl. Probe: the pack lint catches typos at load).
304                if !crate::fake::is_known_generator(arg) {
305                    return Err(ResolveError::FakeUnknown {
306                        kind: arg.to_owned(),
307                        suggestion: crate::matcher::closest(
308                            arg,
309                            crate::fake::GENERATORS.iter().copied(),
310                        )
311                        .map(ToOwned::to_owned),
312                    });
313                }
314                let occurrence = resolution.fakes;
315                resolution.fakes += 1;
316                crate::fake::generate(ctx.run_id, occurrence, arg).ok_or_else(|| {
317                    ResolveError::FakeUnknown {
318                        kind: arg.to_owned(),
319                        suggestion: None,
320                    }
321                })
322            }
323            "url" | "vars" => resolve_config_var(name, namespace, arg, ctx),
324            other => Err(ResolveError::UnknownNamespace {
325                namespace: other.to_owned(),
326            }),
327        };
328    }
329
330    // Plain name: args > defaults (TECH-SPEC §8).
331    for scope in [ctx.args, ctx.defaults] {
332        if let Some(value) = scope.get(name) {
333            return Ok(value.clone());
334        }
335    }
336    let known = ctx.args.keys().chain(ctx.defaults.keys());
337    probe_or(
338        ResolveError::UnknownVariable {
339            name: name.to_owned(),
340            suggestion: crate::matcher::closest(name, known.map(String::as_str))
341                .map(ToOwned::to_owned),
342        },
343        ctx.mode,
344    )
345}
346
347/// `${url:key}` / `${vars:key}` — the injected `proef.toml` scope (base + active
348/// `[env.<name>]`, already deep-merged by the CLI). Lower-time values, so a
349/// missing one errors like `${env:…}` (Probe tolerates it for the pack lint).
350/// `name` is the full `"<namespace>:<key>"` reference (the `config_vars` key), so
351/// the lookup needs no re-`format!`.
352fn resolve_config_var(
353    name: &str,
354    namespace: &str,
355    arg: &str,
356    ctx: &ResolveCtx<'_>,
357) -> Result<String, ResolveError> {
358    match ctx.config_vars.get(name) {
359        Some(value) => Ok(value.clone()),
360        None => probe_or(
361            ResolveError::MissingConfigVar {
362                namespace: namespace.to_owned(),
363                key: arg.to_owned(),
364            },
365            ctx.mode,
366        ),
367    }
368}
369
370/// In [`ResolveMode::Probe`], soften might-resolve-later failures to the
371/// `probe` placeholder; otherwise propagate the error.
372fn probe_or(err: ResolveError, mode: ResolveMode) -> Result<String, ResolveError> {
373    if mode == ResolveMode::Probe {
374        Ok("probe".to_owned())
375    } else {
376        Err(err)
377    }
378}
379
380/// Apply `$${` → `${` escapes (after the final pass — never re-resolved).
381fn unescape(text: &str) -> String {
382    text.replace("$${", "${")
383}
384
385#[cfg(test)]
386mod tests {
387    #![allow(clippy::unwrap_used)]
388
389    use super::*;
390    use crate::world::{GlobalStore, Value};
391
392    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
393        pairs
394            .iter()
395            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
396            .collect()
397    }
398
399    struct Fixture {
400        args: BTreeMap<String, String>,
401        defaults: BTreeMap<String, String>,
402        env: BTreeMap<String, String>,
403        config_vars: BTreeMap<String, String>,
404        world: World,
405    }
406
407    impl Fixture {
408        fn new() -> Self {
409            let mut store = GlobalStore::new();
410            store.insert("recordId", Value::String("r-42".into()));
411            Self {
412                args: map(&[("recordRef", "r-${run:id}")]),
413                defaults: map(&[("index", "records")]),
414                env: map(&[("HOME", "/home/test")]),
415                config_vars: map(&[
416                    ("url:base", "https://api.example"),
417                    ("vars:apiVersion", "v1"),
418                ]),
419                world: World::new(store),
420            }
421        }
422
423        fn ctx(&self, mode: ResolveMode) -> ResolveCtx<'_> {
424            ResolveCtx {
425                args: &self.args,
426                defaults: &self.defaults,
427                env: &self.env,
428                config_vars: &self.config_vars,
429                run_id: "run-0001",
430                world: &self.world,
431                mode,
432            }
433        }
434    }
435
436    #[test]
437    fn scope_precedence_and_recursion() {
438        let f = Fixture::new();
439        // The captured arg itself contains ${run:id} — the spike-verified case.
440        let r = resolve(
441            "GET ${url:base}/search?q=${recordRef}",
442            &f.ctx(ResolveMode::Strict),
443        )
444        .unwrap();
445        assert_eq!(r.text, "GET https://api.example/search?q=r-run-0001");
446    }
447
448    #[test]
449    fn runtime_tier_passes_through() {
450        let f = Fixture::new();
451        let r = resolve(
452            "Authorization: Bearer {{token}}",
453            &f.ctx(ResolveMode::Strict),
454        )
455        .unwrap();
456        assert_eq!(r.text, "Authorization: Bearer {{token}}");
457    }
458
459    #[test]
460    fn escape_round_trips() {
461        let f = Fixture::new();
462        let r = resolve("literal $${notavar} stays", &f.ctx(ResolveMode::Strict)).unwrap();
463        assert_eq!(r.text, "literal ${notavar} stays");
464    }
465
466    #[test]
467    fn env_defaults_apply() {
468        let f = Fixture::new();
469        let ctx = f.ctx(ResolveMode::Strict);
470        assert_eq!(resolve("${env:HOME}", &ctx).unwrap().text, "/home/test");
471        assert_eq!(
472            resolve("${env:NOPE:-fallback}", &ctx).unwrap().text,
473            "fallback"
474        );
475        let err = resolve("${env:NOPE}", &ctx).unwrap_err();
476        assert_eq!(err.code(), "proef::resolve::missing_env");
477    }
478
479    #[test]
480    fn secrets_become_runtime_placeholders_and_are_recorded() {
481        let f = Fixture::new();
482        let r = resolve("Bearer ${secret:apiToken}", &f.ctx(ResolveMode::Strict)).unwrap();
483        assert_eq!(r.text, "Bearer {{apiToken}}");
484        assert!(r.secrets.contains("apiToken"));
485    }
486
487    #[test]
488    fn globals_read_from_the_world() {
489        let f = Fixture::new();
490        let r = resolve("id=${global:recordId}", &f.ctx(ResolveMode::Strict)).unwrap();
491        assert_eq!(r.text, "id=r-42");
492    }
493
494    #[test]
495    fn config_vars_resolve_from_the_injected_scope() {
496        let f = Fixture::new();
497        let r = resolve(
498            "${url:base}/v/${vars:apiVersion}",
499            &f.ctx(ResolveMode::Strict),
500        )
501        .unwrap();
502        assert_eq!(r.text, "https://api.example/v/v1");
503    }
504
505    #[test]
506    fn missing_config_var_errors_in_strict_and_dry_run_but_probes() {
507        let f = Fixture::new();
508        let err = resolve("${url:admin}", &f.ctx(ResolveMode::Strict)).unwrap_err();
509        assert_eq!(err.code(), "proef::resolve::missing_config_var");
510        // Lower-time, not runtime: dry-run must also reject (unlike ${global:…}).
511        assert!(resolve("${vars:nope}", &f.ctx(ResolveMode::DryRun)).is_err());
512        // Probe (pack-lint) tolerates it.
513        assert!(resolve("${url:admin}", &f.ctx(ResolveMode::Probe)).is_ok());
514    }
515
516    #[test]
517    fn missing_global_is_strict_error_but_dry_run_warning() {
518        let f = Fixture::new();
519        let err = resolve("${global:nope}", &f.ctx(ResolveMode::Strict)).unwrap_err();
520        assert_eq!(err.code(), "proef::resolve::missing_global");
521
522        let r = resolve("${global:nope}", &f.ctx(ResolveMode::DryRun)).unwrap();
523        assert_eq!(r.text, "");
524        assert_eq!(r.warnings.len(), 1);
525    }
526
527    #[test]
528    fn unknown_variable_suggests_the_closest_name() {
529        let f = Fixture::new();
530        let err = resolve("${recordRe}", &f.ctx(ResolveMode::Strict)).unwrap_err();
531        let ResolveError::UnknownVariable { suggestion, .. } = &err else {
532            panic!("wrong variant: {err:?}");
533        };
534        assert_eq!(suggestion.as_deref(), Some("recordRef"));
535    }
536
537    #[test]
538    fn reference_cycles_hit_the_depth_cap() {
539        let mut f = Fixture::new();
540        f.args = map(&[("a", "${b}"), ("b", "${a}")]);
541        let err = resolve("${a}", &f.ctx(ResolveMode::Strict)).unwrap_err();
542        assert_eq!(err.code(), "proef::resolve::depth_exceeded");
543    }
544
545    #[test]
546    fn fakes_generate_deterministically_and_reject_typos() {
547        let f = Fixture::new();
548        let once = resolve(
549            "${fake:firstName} ${fake:firstName}",
550            &f.ctx(ResolveMode::Strict),
551        )
552        .unwrap()
553        .text;
554        let twice = resolve(
555            "${fake:firstName} ${fake:firstName}",
556            &f.ctx(ResolveMode::Strict),
557        )
558        .unwrap()
559        .text;
560        assert_eq!(once, twice, "deterministic per run id");
561        assert!(!once.trim().is_empty());
562
563        let err = resolve("${fake:firstNam}", &f.ctx(ResolveMode::Strict)).unwrap_err();
564        assert_eq!(err.code(), "proef::resolve::fake_unknown");
565        assert!(err.to_string().contains("firstName"), "{err}");
566        // Typos are static: even Probe mode rejects them (pack lint).
567        assert!(resolve("${fake:firstNam}", &f.ctx(ResolveMode::Probe)).is_err());
568    }
569
570    #[test]
571    fn unclosed_reference_is_literal() {
572        let f = Fixture::new();
573        let r = resolve("half ${open and done", &f.ctx(ResolveMode::Strict)).unwrap();
574        assert_eq!(r.text, "half ${open and done");
575    }
576
577    mod properties {
578        #![allow(clippy::ignored_unit_patterns)]
579
580        use super::*;
581        use proptest::prelude::*;
582
583        fn empty_ctx_fixture() -> Fixture {
584            let mut f = Fixture::new();
585            f.args = BTreeMap::new();
586            f.defaults = BTreeMap::new();
587            f
588        }
589
590        proptest! {
591            /// Total on arbitrary input: resolve never panics (mirrors the fuzz target).
592            #[test]
593            fn resolver_never_panics(text in ".{0,200}") {
594                let f = Fixture::new();
595                let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
596            }
597
598            /// `$${…}` escape round-trip on arbitrary brace-free inner text.
599            #[test]
600            fn escape_round_trip(inner in "[^{}$]{0,40}") {
601                let f = empty_ctx_fixture();
602                let text = format!("$${{{inner}}}");
603                let resolved = resolve(&text, &f.ctx(ResolveMode::Strict)).unwrap();
604                prop_assert_eq!(resolved.text, format!("${{{inner}}}"));
605            }
606
607            /// Once fully resolved (and escape-free), resolution is idempotent.
608            #[test]
609            fn idempotent_after_fixpoint(text in "[^$]{0,120}") {
610                let f = empty_ctx_fixture();
611                let ctx = f.ctx(ResolveMode::Strict);
612                let once = resolve(&text, &ctx).unwrap();
613                let twice = resolve(&once.text, &ctx).unwrap();
614                prop_assert_eq!(&once.text, &twice.text);
615            }
616
617            /// The depth cap always terminates resolution, whatever the scopes hold.
618            #[test]
619            fn always_terminates(
620                keys in proptest::collection::vec("[a-c]{1}", 1..3),
621                text in "[a-c${}]{0,60}",
622            ) {
623                let mut f = empty_ctx_fixture();
624                // Self-referential scopes: worst case for the pass loop.
625                f.args = keys.iter().map(|k| (k.clone(), format!("${{{k}}}"))).collect();
626                let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
627            }
628        }
629    }
630}