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//! feature directives > flow config) · `${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}` (reserved until M5).
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    /// Feature `# key: value` directives.
47    pub directives: &'a BTreeMap<String, String>,
48    /// Flow/project config variables (empty until config loading lands, M3).
49    pub config: &'a BTreeMap<String, String>,
50    /// Injected environment snapshot.
51    pub env: &'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 template 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    /// `${ns:…}` with an unrecognized namespace.
101    #[error("unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake)")]
102    UnknownNamespace {
103        /// The namespace as written.
104        namespace: String,
105    },
106    /// `${run:…}` with something other than `id`.
107    #[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
108    UnknownRunField {
109        /// The field as written.
110        field: String,
111    },
112    /// `${fake:…}` names no known generator (statically rejected).
113    #[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
114    FakeUnknown {
115        /// The requested generator kind.
116        kind: String,
117        /// Closest known generator, when one is near.
118        suggestion: Option<String>,
119    },
120    /// An empty reference `${}`.
121    #[error("empty variable reference `${{}}`")]
122    EmptyReference,
123    /// Still-unresolved `${…}` after [`MAX_DEPTH`] passes — a reference cycle.
124    #[error(
125        "variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
126    )]
127    DepthExceeded {
128        /// A variable still unresolved when the cap was hit.
129        name: String,
130    },
131}
132
133impl ResolveError {
134    /// The stable diagnostic code for this failure.
135    pub fn code(&self) -> &'static str {
136        match self {
137            Self::UnknownVariable { .. } => "proef::resolve::unknown_variable",
138            Self::MissingEnv { .. } => "proef::resolve::missing_env",
139            Self::MissingGlobal { .. } => "proef::resolve::missing_global",
140            Self::UnknownNamespace { .. } => "proef::resolve::unknown_namespace",
141            Self::UnknownRunField { .. } => "proef::resolve::unknown_run_field",
142            Self::FakeUnknown { .. } => "proef::resolve::fake_unknown",
143            Self::EmptyReference => "proef::resolve::empty_reference",
144            Self::DepthExceeded { .. } => "proef::resolve::depth_exceeded",
145        }
146    }
147}
148
149/// Resolve every `${…}` in `text` (recursively, ≤ [`MAX_DEPTH`] passes), leave
150/// `{{…}}` untouched, then apply `$${` escapes. Pure and total.
151pub fn resolve(text: &str, ctx: &ResolveCtx<'_>) -> Result<Resolution, ResolveError> {
152    let mut resolution = Resolution::default();
153    let mut current = text.to_owned();
154
155    for _ in 0..MAX_DEPTH {
156        let (next, substituted) = resolve_pass(&current, ctx, &mut resolution)?;
157        current = next;
158        if !substituted {
159            resolution.text = unescape(&current);
160            return Ok(resolution);
161        }
162    }
163
164    if let Some((name, _, _)) = first_reference(&current) {
165        Err(ResolveError::DepthExceeded {
166            name: name.to_owned(),
167        })
168    } else {
169        resolution.text = unescape(&current);
170        Ok(resolution)
171    }
172}
173
174/// One left-to-right substitution pass. Returns the new text and whether any
175/// reference was substituted.
176fn resolve_pass(
177    text: &str,
178    ctx: &ResolveCtx<'_>,
179    resolution: &mut Resolution,
180) -> Result<(String, bool), ResolveError> {
181    let mut out = String::with_capacity(text.len());
182    let mut rest = text;
183    let mut substituted = false;
184
185    while let Some((name, start, end)) = first_reference(rest) {
186        out.push_str(&rest[..start]);
187        let value = lookup(name, ctx, resolution)?;
188        out.push_str(&value);
189        substituted = true;
190        rest = &rest[end..];
191    }
192    out.push_str(rest);
193    Ok((out, substituted))
194}
195
196/// Find the first live `${…}` reference, skipping `$${` escapes. Returns
197/// `(name, start_of_ref, end_after_brace)` in byte offsets.
198fn first_reference(text: &str) -> Option<(&str, usize, usize)> {
199    let bytes = text.as_bytes();
200    let mut i = 0;
201    while i < bytes.len() {
202        if bytes[i] == b'$' {
203            // `$${` — escaped: skip the whole escape marker.
204            if text[i..].starts_with("$${") {
205                i += 3;
206                continue;
207            }
208            if text[i..].starts_with("${") {
209                let after = &text[i + 2..];
210                if let Some(close) = after.find('}') {
211                    let name = &after[..close];
212                    return Some((name, i, i + 2 + close + 1));
213                }
214                // Unclosed `${` — treat as literal text.
215                return None;
216            }
217        }
218        i += 1;
219    }
220    None
221}
222
223/// Resolve one reference name to its substitution value.
224fn lookup(
225    name: &str,
226    ctx: &ResolveCtx<'_>,
227    resolution: &mut Resolution,
228) -> Result<String, ResolveError> {
229    let name = name.trim();
230    if name.is_empty() {
231        return Err(ResolveError::EmptyReference);
232    }
233
234    if let Some((namespace, arg)) = name.split_once(':') {
235        return match namespace {
236            "env" => {
237                let (var, default) = match arg.split_once(":-") {
238                    Some((var, default)) => (var, Some(default)),
239                    None => (arg, None),
240                };
241                match ctx.env.get(var) {
242                    Some(value) => Ok(value.clone()),
243                    None => match default {
244                        Some(default) => Ok(default.to_owned()),
245                        None => probe_or(
246                            ResolveError::MissingEnv {
247                                name: var.to_owned(),
248                            },
249                            ctx.mode,
250                        ),
251                    },
252                }
253            }
254            "run" => {
255                if arg == "id" {
256                    Ok(ctx.run_id.to_owned())
257                } else {
258                    Err(ResolveError::UnknownRunField {
259                        field: arg.to_owned(),
260                    })
261                }
262            }
263            "global" => {
264                resolution.globals.insert(arg.to_owned());
265                match ctx.world.get(arg) {
266                    Some(value) => Ok(value.to_string()),
267                    None => match ctx.mode {
268                        ResolveMode::Strict => Err(ResolveError::MissingGlobal {
269                            key: arg.to_owned(),
270                        }),
271                        ResolveMode::DryRun => {
272                            resolution.warnings.push(format!(
273                                "`${{global:{arg}}}` is not set yet — it may be populated at run time"
274                            ));
275                            Ok(String::new())
276                        }
277                        ResolveMode::Probe => Ok("probe".to_owned()),
278                    },
279                }
280            }
281            "secret" => {
282                resolution.secrets.insert(arg.to_owned());
283                // The run-time placeholder; the engine injects the value via
284                // `insert_secret` (M3) — lowered text never carries it.
285                Ok(format!("{{{{{arg}}}}}"))
286            }
287            "fake" => {
288                // Unknown generators are statically wrong — they error in every
289                // mode (incl. Probe: the pack lint catches typos at load).
290                if !crate::fake::is_known_generator(arg) {
291                    return Err(ResolveError::FakeUnknown {
292                        kind: arg.to_owned(),
293                        suggestion: crate::matcher::closest(
294                            arg,
295                            crate::fake::GENERATORS.iter().copied(),
296                        )
297                        .map(ToOwned::to_owned),
298                    });
299                }
300                let occurrence = resolution.fakes;
301                resolution.fakes += 1;
302                crate::fake::generate(ctx.run_id, occurrence, arg).ok_or_else(|| {
303                    ResolveError::FakeUnknown {
304                        kind: arg.to_owned(),
305                        suggestion: None,
306                    }
307                })
308            }
309            other => Err(ResolveError::UnknownNamespace {
310                namespace: other.to_owned(),
311            }),
312        };
313    }
314
315    // Plain name: args > defaults > directives > config (TECH-SPEC §8).
316    for scope in [ctx.args, ctx.defaults, ctx.directives, ctx.config] {
317        if let Some(value) = scope.get(name) {
318            return Ok(value.clone());
319        }
320    }
321    let known = ctx
322        .args
323        .keys()
324        .chain(ctx.defaults.keys())
325        .chain(ctx.directives.keys())
326        .chain(ctx.config.keys());
327    probe_or(
328        ResolveError::UnknownVariable {
329            name: name.to_owned(),
330            suggestion: crate::matcher::closest(name, known.map(String::as_str))
331                .map(ToOwned::to_owned),
332        },
333        ctx.mode,
334    )
335}
336
337/// In [`ResolveMode::Probe`], soften might-resolve-later failures to the
338/// `probe` placeholder; otherwise propagate the error.
339fn probe_or(err: ResolveError, mode: ResolveMode) -> Result<String, ResolveError> {
340    if mode == ResolveMode::Probe {
341        Ok("probe".to_owned())
342    } else {
343        Err(err)
344    }
345}
346
347/// Apply `$${` → `${` escapes (after the final pass — never re-resolved).
348fn unescape(text: &str) -> String {
349    text.replace("$${", "${")
350}
351
352#[cfg(test)]
353mod tests {
354    #![allow(clippy::unwrap_used)]
355
356    use super::*;
357    use crate::world::{GlobalStore, Value};
358
359    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
360        pairs
361            .iter()
362            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
363            .collect()
364    }
365
366    struct Fixture {
367        args: BTreeMap<String, String>,
368        defaults: BTreeMap<String, String>,
369        directives: BTreeMap<String, String>,
370        config: BTreeMap<String, String>,
371        env: BTreeMap<String, String>,
372        world: World,
373    }
374
375    impl Fixture {
376        fn new() -> Self {
377            let mut store = GlobalStore::new();
378            store.insert("clientId", Value::String("c-42".into()));
379            Self {
380                args: map(&[("lastName", "Bakker-${run:id}")]),
381                defaults: map(&[("index", "clients")]),
382                directives: map(&[("baseURL", "http://fixture.local")]),
383                config: BTreeMap::new(),
384                env: map(&[("HOME", "/home/test")]),
385                world: World::new(store),
386            }
387        }
388
389        fn ctx(&self, mode: ResolveMode) -> ResolveCtx<'_> {
390            ResolveCtx {
391                args: &self.args,
392                defaults: &self.defaults,
393                directives: &self.directives,
394                config: &self.config,
395                env: &self.env,
396                run_id: "run-0001",
397                world: &self.world,
398                mode,
399            }
400        }
401    }
402
403    #[test]
404    fn scope_precedence_and_recursion() {
405        let f = Fixture::new();
406        // The captured arg itself contains ${run:id} — the spike-verified case.
407        let r = resolve(
408            "GET ${baseURL}/search?q=${lastName}",
409            &f.ctx(ResolveMode::Strict),
410        )
411        .unwrap();
412        assert_eq!(r.text, "GET http://fixture.local/search?q=Bakker-run-0001");
413    }
414
415    #[test]
416    fn runtime_tier_passes_through() {
417        let f = Fixture::new();
418        let r = resolve(
419            "Authorization: Bearer {{token}}",
420            &f.ctx(ResolveMode::Strict),
421        )
422        .unwrap();
423        assert_eq!(r.text, "Authorization: Bearer {{token}}");
424    }
425
426    #[test]
427    fn escape_round_trips() {
428        let f = Fixture::new();
429        let r = resolve("literal $${notavar} stays", &f.ctx(ResolveMode::Strict)).unwrap();
430        assert_eq!(r.text, "literal ${notavar} stays");
431    }
432
433    #[test]
434    fn env_defaults_apply() {
435        let f = Fixture::new();
436        let ctx = f.ctx(ResolveMode::Strict);
437        assert_eq!(resolve("${env:HOME}", &ctx).unwrap().text, "/home/test");
438        assert_eq!(
439            resolve("${env:NOPE:-fallback}", &ctx).unwrap().text,
440            "fallback"
441        );
442        let err = resolve("${env:NOPE}", &ctx).unwrap_err();
443        assert_eq!(err.code(), "proef::resolve::missing_env");
444    }
445
446    #[test]
447    fn secrets_become_runtime_placeholders_and_are_recorded() {
448        let f = Fixture::new();
449        let r = resolve("Bearer ${secret:apiToken}", &f.ctx(ResolveMode::Strict)).unwrap();
450        assert_eq!(r.text, "Bearer {{apiToken}}");
451        assert!(r.secrets.contains("apiToken"));
452    }
453
454    #[test]
455    fn globals_read_from_the_world() {
456        let f = Fixture::new();
457        let r = resolve("id=${global:clientId}", &f.ctx(ResolveMode::Strict)).unwrap();
458        assert_eq!(r.text, "id=c-42");
459    }
460
461    #[test]
462    fn missing_global_is_strict_error_but_dry_run_warning() {
463        let f = Fixture::new();
464        let err = resolve("${global:nope}", &f.ctx(ResolveMode::Strict)).unwrap_err();
465        assert_eq!(err.code(), "proef::resolve::missing_global");
466
467        let r = resolve("${global:nope}", &f.ctx(ResolveMode::DryRun)).unwrap();
468        assert_eq!(r.text, "");
469        assert_eq!(r.warnings.len(), 1);
470    }
471
472    #[test]
473    fn unknown_variable_suggests_the_closest_name() {
474        let f = Fixture::new();
475        let err = resolve("${lastNam}", &f.ctx(ResolveMode::Strict)).unwrap_err();
476        let ResolveError::UnknownVariable { suggestion, .. } = &err else {
477            panic!("wrong variant: {err:?}");
478        };
479        assert_eq!(suggestion.as_deref(), Some("lastName"));
480    }
481
482    #[test]
483    fn reference_cycles_hit_the_depth_cap() {
484        let mut f = Fixture::new();
485        f.args = map(&[("a", "${b}"), ("b", "${a}")]);
486        let err = resolve("${a}", &f.ctx(ResolveMode::Strict)).unwrap_err();
487        assert_eq!(err.code(), "proef::resolve::depth_exceeded");
488    }
489
490    #[test]
491    fn fakes_generate_deterministically_and_reject_typos() {
492        let f = Fixture::new();
493        let once = resolve(
494            "${fake:firstName} ${fake:firstName}",
495            &f.ctx(ResolveMode::Strict),
496        )
497        .unwrap()
498        .text;
499        let twice = resolve(
500            "${fake:firstName} ${fake:firstName}",
501            &f.ctx(ResolveMode::Strict),
502        )
503        .unwrap()
504        .text;
505        assert_eq!(once, twice, "deterministic per run id");
506        assert!(!once.trim().is_empty());
507
508        let err = resolve("${fake:firstNam}", &f.ctx(ResolveMode::Strict)).unwrap_err();
509        assert_eq!(err.code(), "proef::resolve::fake_unknown");
510        assert!(err.to_string().contains("firstName"), "{err}");
511        // Typos are static: even Probe mode rejects them (pack lint).
512        assert!(resolve("${fake:firstNam}", &f.ctx(ResolveMode::Probe)).is_err());
513    }
514
515    #[test]
516    fn unclosed_reference_is_literal() {
517        let f = Fixture::new();
518        let r = resolve("half ${open and done", &f.ctx(ResolveMode::Strict)).unwrap();
519        assert_eq!(r.text, "half ${open and done");
520    }
521
522    mod properties {
523        #![allow(clippy::ignored_unit_patterns)]
524
525        use super::*;
526        use proptest::prelude::*;
527
528        fn empty_ctx_fixture() -> Fixture {
529            let mut f = Fixture::new();
530            f.args = BTreeMap::new();
531            f.defaults = BTreeMap::new();
532            f.directives = BTreeMap::new();
533            f
534        }
535
536        proptest! {
537            /// Total on arbitrary input: resolve never panics (mirrors the fuzz target).
538            #[test]
539            fn resolver_never_panics(text in ".{0,200}") {
540                let f = Fixture::new();
541                let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
542            }
543
544            /// `$${…}` escape round-trip on arbitrary brace-free inner text.
545            #[test]
546            fn escape_round_trip(inner in "[^{}$]{0,40}") {
547                let f = empty_ctx_fixture();
548                let text = format!("$${{{inner}}}");
549                let resolved = resolve(&text, &f.ctx(ResolveMode::Strict)).unwrap();
550                prop_assert_eq!(resolved.text, format!("${{{inner}}}"));
551            }
552
553            /// Once fully resolved (and escape-free), resolution is idempotent.
554            #[test]
555            fn idempotent_after_fixpoint(text in "[^$]{0,120}") {
556                let f = empty_ctx_fixture();
557                let ctx = f.ctx(ResolveMode::Strict);
558                let once = resolve(&text, &ctx).unwrap();
559                let twice = resolve(&once.text, &ctx).unwrap();
560                prop_assert_eq!(&once.text, &twice.text);
561            }
562
563            /// The depth cap always terminates resolution, whatever the scopes hold.
564            #[test]
565            fn always_terminates(
566                keys in proptest::collection::vec("[a-c]{1}", 1..3),
567                text in "[a-c${}]{0,60}",
568            ) {
569                let mut f = empty_ctx_fixture();
570                // Self-referential scopes: worst case for the pass loop.
571                f.args = keys.iter().map(|k| (k.clone(), format!("${{{k}}}"))).collect();
572                let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
573            }
574        }
575    }
576}