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