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    /// Dry-run soft findings (e.g. a runtime-only global).
70    pub warnings: Vec<String>,
71}
72
73/// Why a `${…}` reference 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    /// `${url:key}` / `${vars:key}` referencing a value defined in neither the
99    /// base `proef.toml` table nor the active `[env.<name>]` profile.
100    #[error(
101        "{namespace} variable `{key}` is not set — define `[{namespace}]` `{key}` in proef.toml (or in the active `[env.<name>.{namespace}]`){}",
102        suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default()
103    )]
104    MissingConfigVar {
105        /// The namespace as written (`url` or `vars`).
106        namespace: String,
107        /// The referenced key.
108        key: String,
109        /// Closest key defined in the same namespace, when one is near.
110        suggestion: Option<String>,
111    },
112    /// `${ns:…}` with an unrecognized namespace.
113    #[error(
114        "unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake, url, vars)"
115    )]
116    UnknownNamespace {
117        /// The namespace as written.
118        namespace: String,
119    },
120    /// `${run:…}` with something other than `id`.
121    #[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
122    UnknownRunField {
123        /// The field as written.
124        field: String,
125    },
126    /// `${fake:…}` names no known generator (statically rejected).
127    #[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
128    FakeUnknown {
129        /// The requested generator kind.
130        kind: String,
131        /// Closest known generator, when one is near.
132        suggestion: Option<String>,
133    },
134    /// An empty reference `${}`.
135    #[error("empty variable reference `${{}}`")]
136    EmptyReference,
137    /// Still-unresolved `${…}` after [`MAX_DEPTH`] passes — a reference cycle.
138    #[error(
139        "variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
140    )]
141    DepthExceeded {
142        /// A variable still unresolved when the cap was hit.
143        name: String,
144    },
145}
146
147impl ResolveError {
148    /// The stable diagnostic code for this failure.
149    pub fn code(&self) -> &'static str {
150        match self {
151            Self::UnknownVariable { .. } => "proef::resolve::unknown_variable",
152            Self::MissingEnv { .. } => "proef::resolve::missing_env",
153            Self::MissingConfigVar { .. } => "proef::resolve::missing_config_var",
154            Self::MissingGlobal { .. } => "proef::resolve::missing_global",
155            Self::UnknownNamespace { .. } => "proef::resolve::unknown_namespace",
156            Self::UnknownRunField { .. } => "proef::resolve::unknown_run_field",
157            Self::FakeUnknown { .. } => "proef::resolve::fake_unknown",
158            Self::EmptyReference => "proef::resolve::empty_reference",
159            Self::DepthExceeded { .. } => "proef::resolve::depth_exceeded",
160        }
161    }
162}
163
164/// Resolve every `${…}` in `text` (recursively, ≤ [`MAX_DEPTH`] passes), leave
165/// `{{…}}` untouched, then apply `$${` escapes. Pure and total.
166///
167/// `fakes` is the `${fake:*}` occurrence counter — owned by the caller and
168/// carried across every `resolve()` call in its scope (a scenario's steps,
169/// TECH-SPEC §8), so two references to the same generator never collide. It
170/// is read and incremented, never reset here; a fresh scope means a fresh
171/// `0`-initialized counter, which is what keeps values a pure function of
172/// `(run_id, generator, occurrence)` — still deterministic per `run_id`.
173pub fn resolve(
174    text: &str,
175    ctx: &ResolveCtx<'_>,
176    fakes: &mut usize,
177) -> Result<Resolution, ResolveError> {
178    let mut resolution = Resolution::default();
179    let mut current = text.to_owned();
180
181    for _ in 0..MAX_DEPTH {
182        let (next, substituted) = resolve_pass(&current, ctx, &mut resolution, fakes)?;
183        current = next;
184        if !substituted {
185            resolution.text = unescape(&current);
186            return Ok(resolution);
187        }
188    }
189
190    if let Some((name, _, _)) = first_reference(&current) {
191        Err(ResolveError::DepthExceeded {
192            name: name.to_owned(),
193        })
194    } else {
195        resolution.text = unescape(&current);
196        Ok(resolution)
197    }
198}
199
200/// One left-to-right substitution pass. Returns the new text and whether any
201/// reference was substituted.
202fn resolve_pass(
203    text: &str,
204    ctx: &ResolveCtx<'_>,
205    resolution: &mut Resolution,
206    fakes: &mut usize,
207) -> Result<(String, bool), ResolveError> {
208    let mut out = String::with_capacity(text.len());
209    let mut rest = text;
210    let mut substituted = false;
211
212    while let Some((name, start, end)) = first_reference(rest) {
213        out.push_str(&rest[..start]);
214        let value = lookup(name, ctx, resolution, fakes)?;
215        out.push_str(&value);
216        substituted = true;
217        rest = &rest[end..];
218    }
219    out.push_str(rest);
220    Ok((out, substituted))
221}
222
223/// Find the first live `${…}` reference, skipping `$${` escapes. Returns
224/// `(name, start_of_ref, end_after_brace)` in byte offsets.
225pub(crate) fn first_reference(text: &str) -> Option<(&str, usize, usize)> {
226    let bytes = text.as_bytes();
227    let mut i = 0;
228    while i < bytes.len() {
229        if bytes[i] == b'$' {
230            // `$${` — escaped: skip the whole escape marker.
231            if text[i..].starts_with("$${") {
232                i += 3;
233                continue;
234            }
235            if text[i..].starts_with("${") {
236                let after = &text[i + 2..];
237                if let Some(close) = after.find('}') {
238                    let name = &after[..close];
239                    return Some((name, i, i + 2 + close + 1));
240                }
241                // Unclosed `${` — treat as literal text.
242                return None;
243            }
244        }
245        i += 1;
246    }
247    None
248}
249
250/// Resolve one reference name to its substitution value.
251fn lookup(
252    name: &str,
253    ctx: &ResolveCtx<'_>,
254    resolution: &mut Resolution,
255    fakes: &mut usize,
256) -> Result<String, ResolveError> {
257    let name = name.trim();
258    if name.is_empty() {
259        return Err(ResolveError::EmptyReference);
260    }
261
262    if let Some((namespace, arg)) = name.split_once(':') {
263        return match namespace {
264            "env" => {
265                let (var, default) = match arg.split_once(":-") {
266                    Some((var, default)) => (var, Some(default)),
267                    None => (arg, None),
268                };
269                match ctx.env.get(var) {
270                    Some(value) => Ok(value.clone()),
271                    None => match default {
272                        Some(default) => Ok(default.to_owned()),
273                        None => probe_or(
274                            ResolveError::MissingEnv {
275                                name: var.to_owned(),
276                            },
277                            ctx.mode,
278                        ),
279                    },
280                }
281            }
282            "run" => {
283                if arg == "id" {
284                    Ok(ctx.run_id.to_owned())
285                } else {
286                    Err(ResolveError::UnknownRunField {
287                        field: arg.to_owned(),
288                    })
289                }
290            }
291            "global" => {
292                resolution.globals.insert(arg.to_owned());
293                match ctx.world.get(arg) {
294                    Some(value) => Ok(value.to_string()),
295                    None => match ctx.mode {
296                        ResolveMode::Strict => Err(ResolveError::MissingGlobal {
297                            key: arg.to_owned(),
298                        }),
299                        ResolveMode::DryRun => {
300                            resolution.warnings.push(format!(
301                                "`${{global:{arg}}}` is not set yet — it may be populated at run time"
302                            ));
303                            Ok(String::new())
304                        }
305                        ResolveMode::Probe => Ok("probe".to_owned()),
306                    },
307                }
308            }
309            "secret" => {
310                resolution.secrets.insert(arg.to_owned());
311                // The run-time placeholder; the engine injects the value via
312                // `insert_secret` at run time — lowered text never carries it.
313                Ok(format!("{{{{{arg}}}}}"))
314            }
315            "fake" => {
316                // Unknown generators are statically wrong — they error in every
317                // mode (incl. Probe: the pack lint catches typos at load).
318                if !crate::fake::is_known_generator(arg) {
319                    return Err(ResolveError::FakeUnknown {
320                        kind: arg.to_owned(),
321                        suggestion: crate::matcher::closest(
322                            arg,
323                            crate::fake::GENERATORS.iter().copied(),
324                        )
325                        .map(ToOwned::to_owned),
326                    });
327                }
328                let occurrence = *fakes;
329                *fakes += 1;
330                crate::fake::generate(ctx.run_id, occurrence, arg).ok_or_else(|| {
331                    ResolveError::FakeUnknown {
332                        kind: arg.to_owned(),
333                        suggestion: None,
334                    }
335                })
336            }
337            "url" | "vars" => resolve_config_var(name, namespace, arg, ctx),
338            other => Err(ResolveError::UnknownNamespace {
339                namespace: other.to_owned(),
340            }),
341        };
342    }
343
344    // Plain name: args > defaults (TECH-SPEC §8).
345    for scope in [ctx.args, ctx.defaults] {
346        if let Some(value) = scope.get(name) {
347            return Ok(value.clone());
348        }
349    }
350    let known = ctx.args.keys().chain(ctx.defaults.keys());
351    probe_or(
352        ResolveError::UnknownVariable {
353            name: name.to_owned(),
354            suggestion: crate::matcher::closest(name, known.map(String::as_str))
355                .map(ToOwned::to_owned),
356        },
357        ctx.mode,
358    )
359}
360
361/// `${url:key}` / `${vars:key}` — the injected `proef.toml` scope (base + active
362/// `[env.<name>]`, already deep-merged by the CLI). Lower-time values, so a
363/// missing one errors like `${env:…}` (Probe tolerates it for the pack lint).
364/// `name` is the full `"<namespace>:<key>"` reference (the `config_vars` key), so
365/// the lookup needs no re-`format!`.
366fn resolve_config_var(
367    name: &str,
368    namespace: &str,
369    arg: &str,
370    ctx: &ResolveCtx<'_>,
371) -> Result<String, ResolveError> {
372    if let Some(value) = ctx.config_vars.get(name) {
373        return Ok(value.clone());
374    }
375    // Candidates are scoped to the same namespace, so a `url:` typo can never
376    // suggest a `vars:` key. Keys are stored as `namespace:key`.
377    let prefix = format!("{namespace}:");
378    let suggestion = crate::matcher::closest(
379        arg,
380        ctx.config_vars
381            .keys()
382            .filter_map(|k| k.strip_prefix(&prefix)),
383    )
384    .map(str::to_owned);
385    probe_or(
386        ResolveError::MissingConfigVar {
387            namespace: namespace.to_owned(),
388            key: arg.to_owned(),
389            suggestion,
390        },
391        ctx.mode,
392    )
393}
394
395/// In [`ResolveMode::Probe`], soften might-resolve-later failures to the
396/// `probe` placeholder; otherwise propagate the error.
397fn probe_or(err: ResolveError, mode: ResolveMode) -> Result<String, ResolveError> {
398    if mode == ResolveMode::Probe {
399        Ok("probe".to_owned())
400    } else {
401        Err(err)
402    }
403}
404
405/// Apply `$${` → `${` escapes (after the final pass — never re-resolved).
406fn unescape(text: &str) -> String {
407    text.replace("$${", "${")
408}
409
410#[cfg(test)]
411mod tests {
412    #![allow(clippy::unwrap_used)]
413
414    use super::*;
415    use crate::world::{GlobalStore, Value};
416
417    fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
418        pairs
419            .iter()
420            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
421            .collect()
422    }
423
424    struct Fixture {
425        args: BTreeMap<String, String>,
426        defaults: BTreeMap<String, String>,
427        env: BTreeMap<String, String>,
428        config_vars: BTreeMap<String, String>,
429        world: World,
430    }
431
432    impl Fixture {
433        fn new() -> Self {
434            let mut store = GlobalStore::new();
435            store.insert("recordId", Value::String("r-42".into()));
436            Self {
437                args: map(&[("recordRef", "r-${run:id}")]),
438                defaults: map(&[("index", "records")]),
439                env: map(&[("HOME", "/home/test")]),
440                config_vars: map(&[
441                    ("url:base", "https://api.example"),
442                    ("vars:apiVersion", "v1"),
443                    // Edit-distance-1 from the `${url:nearvar}` typo used by
444                    // missing_config_var_never_suggests_across_namespaces —
445                    // deliberately placed in the wrong namespace.
446                    ("vars:nearvars", "v2"),
447                ]),
448                world: World::new(store),
449            }
450        }
451
452        fn ctx(&self, mode: ResolveMode) -> ResolveCtx<'_> {
453            ResolveCtx {
454                args: &self.args,
455                defaults: &self.defaults,
456                env: &self.env,
457                config_vars: &self.config_vars,
458                run_id: "run-0001",
459                world: &self.world,
460                mode,
461            }
462        }
463    }
464
465    #[test]
466    fn scope_precedence_and_recursion() {
467        let f = Fixture::new();
468        // The captured arg itself contains ${run:id} — the spike-verified case.
469        let r = resolve(
470            "GET ${url:base}/search?q=${recordRef}",
471            &f.ctx(ResolveMode::Strict),
472            &mut 0,
473        )
474        .unwrap();
475        assert_eq!(r.text, "GET https://api.example/search?q=r-run-0001");
476    }
477
478    #[test]
479    fn runtime_tier_passes_through() {
480        let f = Fixture::new();
481        let r = resolve(
482            "Authorization: Bearer {{token}}",
483            &f.ctx(ResolveMode::Strict),
484            &mut 0,
485        )
486        .unwrap();
487        assert_eq!(r.text, "Authorization: Bearer {{token}}");
488    }
489
490    #[test]
491    fn escape_round_trips() {
492        let f = Fixture::new();
493        let r = resolve(
494            "literal $${notavar} stays",
495            &f.ctx(ResolveMode::Strict),
496            &mut 0,
497        )
498        .unwrap();
499        assert_eq!(r.text, "literal ${notavar} stays");
500    }
501
502    #[test]
503    fn env_defaults_apply() {
504        let f = Fixture::new();
505        let ctx = f.ctx(ResolveMode::Strict);
506        assert_eq!(
507            resolve("${env:HOME}", &ctx, &mut 0).unwrap().text,
508            "/home/test"
509        );
510        assert_eq!(
511            resolve("${env:NOPE:-fallback}", &ctx, &mut 0).unwrap().text,
512            "fallback"
513        );
514        let err = resolve("${env:NOPE}", &ctx, &mut 0).unwrap_err();
515        assert_eq!(err.code(), "proef::resolve::missing_env");
516    }
517
518    #[test]
519    fn secrets_become_runtime_placeholders_and_are_recorded() {
520        let f = Fixture::new();
521        let r = resolve(
522            "Bearer ${secret:apiToken}",
523            &f.ctx(ResolveMode::Strict),
524            &mut 0,
525        )
526        .unwrap();
527        assert_eq!(r.text, "Bearer {{apiToken}}");
528        assert!(r.secrets.contains("apiToken"));
529    }
530
531    #[test]
532    fn globals_read_from_the_world() {
533        let f = Fixture::new();
534        let r = resolve("id=${global:recordId}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap();
535        assert_eq!(r.text, "id=r-42");
536    }
537
538    #[test]
539    fn config_vars_resolve_from_the_injected_scope() {
540        let f = Fixture::new();
541        let r = resolve(
542            "${url:base}/v/${vars:apiVersion}",
543            &f.ctx(ResolveMode::Strict),
544            &mut 0,
545        )
546        .unwrap();
547        assert_eq!(r.text, "https://api.example/v/v1");
548    }
549
550    #[test]
551    fn missing_config_var_errors_in_strict_and_dry_run_but_probes() {
552        let f = Fixture::new();
553        let err = resolve("${url:admin}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
554        assert_eq!(err.code(), "proef::resolve::missing_config_var");
555        // Lower-time, not runtime: dry-run must also reject (unlike ${global:…}).
556        assert!(resolve("${vars:nope}", &f.ctx(ResolveMode::DryRun), &mut 0).is_err());
557        // Probe (pack-lint) tolerates it.
558        assert!(resolve("${url:admin}", &f.ctx(ResolveMode::Probe), &mut 0).is_ok());
559    }
560
561    #[test]
562    fn missing_config_var_suggests_the_closest_key_in_the_same_namespace() {
563        let f = Fixture::new();
564        // The fixture defines `url:base`; `bse` is one edit away.
565        let err = resolve("${url:bse}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
566        let message = err.to_string();
567        assert!(
568            message.contains("did you mean `base`"),
569            "expected a suggestion naming the near key, got: {message}"
570        );
571    }
572
573    #[test]
574    fn missing_config_var_never_suggests_across_namespaces() {
575        // A `vars:` key that is edit-closer than any `url:` key must not be
576        // offered for a `${url:…}` typo — candidates are namespace-scoped.
577        let f = Fixture::new();
578        let err = resolve("${url:nearvar}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
579        let message = err.to_string();
580        // Strictly stronger than pinning just the specific candidate name: no
581        // suggestion at all is correct here, since `url:` has no close match
582        // of its own — a future change that started suggesting some *other*
583        // wrong-namespace key would still be caught.
584        assert!(
585            !message.contains("did you mean"),
586            "suggestion crossed namespaces: {message}"
587        );
588    }
589
590    #[test]
591    fn missing_global_is_strict_error_but_dry_run_warning() {
592        let f = Fixture::new();
593        let err = resolve("${global:nope}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
594        assert_eq!(err.code(), "proef::resolve::missing_global");
595
596        let r = resolve("${global:nope}", &f.ctx(ResolveMode::DryRun), &mut 0).unwrap();
597        assert_eq!(r.text, "");
598        assert_eq!(r.warnings.len(), 1);
599    }
600
601    #[test]
602    fn unknown_variable_suggests_the_closest_name() {
603        let f = Fixture::new();
604        let err = resolve("${recordRe}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
605        let ResolveError::UnknownVariable { suggestion, .. } = &err else {
606            panic!("wrong variant: {err:?}");
607        };
608        assert_eq!(suggestion.as_deref(), Some("recordRef"));
609    }
610
611    #[test]
612    fn reference_cycles_hit_the_depth_cap() {
613        let mut f = Fixture::new();
614        f.args = map(&[("a", "${b}"), ("b", "${a}")]);
615        let err = resolve("${a}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
616        assert_eq!(err.code(), "proef::resolve::depth_exceeded");
617    }
618
619    #[test]
620    fn fakes_generate_deterministically_and_reject_typos() {
621        let f = Fixture::new();
622        let once = resolve(
623            "${fake:firstName} ${fake:firstName}",
624            &f.ctx(ResolveMode::Strict),
625            &mut 0,
626        )
627        .unwrap()
628        .text;
629        let twice = resolve(
630            "${fake:firstName} ${fake:firstName}",
631            &f.ctx(ResolveMode::Strict),
632            &mut 0,
633        )
634        .unwrap()
635        .text;
636        assert_eq!(once, twice, "deterministic per run id");
637        assert!(!once.trim().is_empty());
638
639        let err = resolve("${fake:firstNam}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
640        assert_eq!(err.code(), "proef::resolve::fake_unknown");
641        assert!(err.to_string().contains("firstName"), "{err}");
642        // Typos are static: even Probe mode rejects them (pack lint).
643        assert!(resolve("${fake:firstNam}", &f.ctx(ResolveMode::Probe), &mut 0).is_err());
644    }
645
646    /// Resolves two steps under one scenario with a fixed run id, mirroring
647    /// how `lower.rs` threads one occurrence counter across a scenario's
648    /// steps (`Refs::fakes`) instead of resetting it per `resolve()` call.
649    fn resolve_two_steps(a: &str, b: &str) -> (String, String) {
650        let f = Fixture::new();
651        let ctx = f.ctx(ResolveMode::Strict);
652        let mut fakes = 0;
653        let first = resolve(a, &ctx, &mut fakes).unwrap().text;
654        let second = resolve(b, &ctx, &mut fakes).unwrap().text;
655        (first, second)
656    }
657
658    #[test]
659    fn fake_values_do_not_collide_across_steps_in_a_scenario() {
660        // Two steps, each with its own `${fake:email}`, must get distinct
661        // values — the counter belongs to the scenario, not to one resolve().
662        let (first, second) = resolve_two_steps("${fake:email}", "${fake:email}");
663        assert_ne!(
664            first, second,
665            "two steps' fake values collided: {first} == {second}"
666        );
667    }
668
669    #[test]
670    fn fake_values_are_reproducible_for_the_same_run_id() {
671        // Determinism is the property that makes artifacts a contract
672        // (ADR-0010): the same run id must reproduce the same bytes.
673        let first_run = resolve_two_steps("${fake:email}", "${fake:email}");
674        let second_run = resolve_two_steps("${fake:email}", "${fake:email}");
675        assert_eq!(
676            first_run, second_run,
677            "same run id produced different fakes"
678        );
679    }
680
681    #[test]
682    fn unclosed_reference_is_literal() {
683        let f = Fixture::new();
684        let r = resolve("half ${open and done", &f.ctx(ResolveMode::Strict), &mut 0).unwrap();
685        assert_eq!(r.text, "half ${open and done");
686    }
687
688    mod properties {
689        #![allow(clippy::ignored_unit_patterns)]
690
691        use super::*;
692        use proptest::prelude::*;
693
694        fn empty_ctx_fixture() -> Fixture {
695            let mut f = Fixture::new();
696            f.args = BTreeMap::new();
697            f.defaults = BTreeMap::new();
698            f
699        }
700
701        proptest! {
702            /// Total on arbitrary input: resolve never panics (mirrors the fuzz target).
703            #[test]
704            fn resolver_never_panics(text in ".{0,200}") {
705                let f = Fixture::new();
706                let _ = resolve(&text, &f.ctx(ResolveMode::DryRun), &mut 0);
707            }
708
709            /// `$${…}` escape round-trip on arbitrary brace-free inner text.
710            #[test]
711            fn escape_round_trip(inner in "[^{}$]{0,40}") {
712                let f = empty_ctx_fixture();
713                let text = format!("$${{{inner}}}");
714                let resolved = resolve(&text, &f.ctx(ResolveMode::Strict), &mut 0).unwrap();
715                prop_assert_eq!(resolved.text, format!("${{{inner}}}"));
716            }
717
718            /// Once fully resolved (and escape-free), resolution is idempotent.
719            #[test]
720            fn idempotent_after_fixpoint(text in "[^$]{0,120}") {
721                let f = empty_ctx_fixture();
722                let ctx = f.ctx(ResolveMode::Strict);
723                let once = resolve(&text, &ctx, &mut 0).unwrap();
724                let twice = resolve(&once.text, &ctx, &mut 0).unwrap();
725                prop_assert_eq!(&once.text, &twice.text);
726            }
727
728            /// The depth cap always terminates resolution, whatever the scopes hold.
729            #[test]
730            fn always_terminates(
731                keys in proptest::collection::vec("[a-c]{1}", 1..3),
732                text in "[a-c${}]{0,60}",
733            ) {
734                let mut f = empty_ctx_fixture();
735                // Self-referential scopes: worst case for the pass loop.
736                f.args = keys.iter().map(|k| (k.clone(), format!("${{{k}}}"))).collect();
737                let _ = resolve(&text, &f.ctx(ResolveMode::DryRun), &mut 0);
738            }
739        }
740    }
741}