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