use std::collections::{BTreeMap, BTreeSet};
use crate::world::World;
pub const MAX_DEPTH: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolveMode {
Strict,
DryRun,
Probe,
}
#[derive(Debug, Clone, Copy)]
pub struct ResolveCtx<'a> {
pub args: &'a BTreeMap<String, String>,
pub defaults: &'a BTreeMap<String, String>,
pub env: &'a BTreeMap<String, String>,
pub config_vars: &'a BTreeMap<String, String>,
pub run_id: &'a str,
pub world: &'a World,
pub mode: ResolveMode,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Resolution {
pub text: String,
pub secrets: BTreeSet<String>,
pub globals: BTreeSet<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResolveError {
#[error("unknown variable `${{{name}}}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
UnknownVariable {
name: String,
suggestion: Option<String>,
},
#[error(
"environment variable `{name}` is not set (use `${{env:{name}:-default}}` for a fallback)"
)]
MissingEnv {
name: String,
},
#[error("global `{key}` is not set in the World")]
MissingGlobal {
key: String,
},
#[error(
"{namespace} variable `{key}` is not set — define `[{namespace}]` `{key}` in proef.toml (or in the active `[env.<name>.{namespace}]`){}",
suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default()
)]
MissingConfigVar {
namespace: String,
key: String,
suggestion: Option<String>,
},
#[error(
"unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake, url, vars)"
)]
UnknownNamespace {
namespace: String,
},
#[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
UnknownRunField {
field: String,
},
#[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
FakeUnknown {
kind: String,
suggestion: Option<String>,
},
#[error("empty variable reference `${{}}`")]
EmptyReference,
#[error(
"variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
)]
DepthExceeded {
name: String,
},
}
impl ResolveError {
pub fn code(&self) -> &'static str {
match self {
Self::UnknownVariable { .. } => "proef::resolve::unknown_variable",
Self::MissingEnv { .. } => "proef::resolve::missing_env",
Self::MissingConfigVar { .. } => "proef::resolve::missing_config_var",
Self::MissingGlobal { .. } => "proef::resolve::missing_global",
Self::UnknownNamespace { .. } => "proef::resolve::unknown_namespace",
Self::UnknownRunField { .. } => "proef::resolve::unknown_run_field",
Self::FakeUnknown { .. } => "proef::resolve::fake_unknown",
Self::EmptyReference => "proef::resolve::empty_reference",
Self::DepthExceeded { .. } => "proef::resolve::depth_exceeded",
}
}
}
pub fn resolve(
text: &str,
ctx: &ResolveCtx<'_>,
fakes: &mut usize,
) -> Result<Resolution, ResolveError> {
let mut resolution = Resolution::default();
let mut current = text.to_owned();
for _ in 0..MAX_DEPTH {
let (next, substituted) = resolve_pass(¤t, ctx, &mut resolution, fakes)?;
current = next;
if !substituted {
resolution.text = unescape(¤t);
return Ok(resolution);
}
}
if let Some((name, _, _)) = first_reference(¤t) {
Err(ResolveError::DepthExceeded {
name: name.to_owned(),
})
} else {
resolution.text = unescape(¤t);
Ok(resolution)
}
}
fn resolve_pass(
text: &str,
ctx: &ResolveCtx<'_>,
resolution: &mut Resolution,
fakes: &mut usize,
) -> Result<(String, bool), ResolveError> {
let mut out = String::with_capacity(text.len());
let mut rest = text;
let mut substituted = false;
while let Some((name, start, end)) = first_reference(rest) {
out.push_str(&rest[..start]);
let value = lookup(name, ctx, resolution, fakes)?;
out.push_str(&value);
substituted = true;
rest = &rest[end..];
}
out.push_str(rest);
Ok((out, substituted))
}
fn first_reference(text: &str) -> Option<(&str, usize, usize)> {
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' {
if text[i..].starts_with("$${") {
i += 3;
continue;
}
if text[i..].starts_with("${") {
let after = &text[i + 2..];
if let Some(close) = after.find('}') {
let name = &after[..close];
return Some((name, i, i + 2 + close + 1));
}
return None;
}
}
i += 1;
}
None
}
fn lookup(
name: &str,
ctx: &ResolveCtx<'_>,
resolution: &mut Resolution,
fakes: &mut usize,
) -> Result<String, ResolveError> {
let name = name.trim();
if name.is_empty() {
return Err(ResolveError::EmptyReference);
}
if let Some((namespace, arg)) = name.split_once(':') {
return match namespace {
"env" => {
let (var, default) = match arg.split_once(":-") {
Some((var, default)) => (var, Some(default)),
None => (arg, None),
};
match ctx.env.get(var) {
Some(value) => Ok(value.clone()),
None => match default {
Some(default) => Ok(default.to_owned()),
None => probe_or(
ResolveError::MissingEnv {
name: var.to_owned(),
},
ctx.mode,
),
},
}
}
"run" => {
if arg == "id" {
Ok(ctx.run_id.to_owned())
} else {
Err(ResolveError::UnknownRunField {
field: arg.to_owned(),
})
}
}
"global" => {
resolution.globals.insert(arg.to_owned());
match ctx.world.get(arg) {
Some(value) => Ok(value.to_string()),
None => match ctx.mode {
ResolveMode::Strict => Err(ResolveError::MissingGlobal {
key: arg.to_owned(),
}),
ResolveMode::DryRun => {
resolution.warnings.push(format!(
"`${{global:{arg}}}` is not set yet — it may be populated at run time"
));
Ok(String::new())
}
ResolveMode::Probe => Ok("probe".to_owned()),
},
}
}
"secret" => {
resolution.secrets.insert(arg.to_owned());
Ok(format!("{{{{{arg}}}}}"))
}
"fake" => {
if !crate::fake::is_known_generator(arg) {
return Err(ResolveError::FakeUnknown {
kind: arg.to_owned(),
suggestion: crate::matcher::closest(
arg,
crate::fake::GENERATORS.iter().copied(),
)
.map(ToOwned::to_owned),
});
}
let occurrence = *fakes;
*fakes += 1;
crate::fake::generate(ctx.run_id, occurrence, arg).ok_or_else(|| {
ResolveError::FakeUnknown {
kind: arg.to_owned(),
suggestion: None,
}
})
}
"url" | "vars" => resolve_config_var(name, namespace, arg, ctx),
other => Err(ResolveError::UnknownNamespace {
namespace: other.to_owned(),
}),
};
}
for scope in [ctx.args, ctx.defaults] {
if let Some(value) = scope.get(name) {
return Ok(value.clone());
}
}
let known = ctx.args.keys().chain(ctx.defaults.keys());
probe_or(
ResolveError::UnknownVariable {
name: name.to_owned(),
suggestion: crate::matcher::closest(name, known.map(String::as_str))
.map(ToOwned::to_owned),
},
ctx.mode,
)
}
fn resolve_config_var(
name: &str,
namespace: &str,
arg: &str,
ctx: &ResolveCtx<'_>,
) -> Result<String, ResolveError> {
if let Some(value) = ctx.config_vars.get(name) {
return Ok(value.clone());
}
let prefix = format!("{namespace}:");
let suggestion = crate::matcher::closest(
arg,
ctx.config_vars
.keys()
.filter_map(|k| k.strip_prefix(&prefix)),
)
.map(str::to_owned);
probe_or(
ResolveError::MissingConfigVar {
namespace: namespace.to_owned(),
key: arg.to_owned(),
suggestion,
},
ctx.mode,
)
}
fn probe_or(err: ResolveError, mode: ResolveMode) -> Result<String, ResolveError> {
if mode == ResolveMode::Probe {
Ok("probe".to_owned())
} else {
Err(err)
}
}
fn unescape(text: &str) -> String {
text.replace("$${", "${")
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::world::{GlobalStore, Value};
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
struct Fixture {
args: BTreeMap<String, String>,
defaults: BTreeMap<String, String>,
env: BTreeMap<String, String>,
config_vars: BTreeMap<String, String>,
world: World,
}
impl Fixture {
fn new() -> Self {
let mut store = GlobalStore::new();
store.insert("recordId", Value::String("r-42".into()));
Self {
args: map(&[("recordRef", "r-${run:id}")]),
defaults: map(&[("index", "records")]),
env: map(&[("HOME", "/home/test")]),
config_vars: map(&[
("url:base", "https://api.example"),
("vars:apiVersion", "v1"),
("vars:nearvars", "v2"),
]),
world: World::new(store),
}
}
fn ctx(&self, mode: ResolveMode) -> ResolveCtx<'_> {
ResolveCtx {
args: &self.args,
defaults: &self.defaults,
env: &self.env,
config_vars: &self.config_vars,
run_id: "run-0001",
world: &self.world,
mode,
}
}
}
#[test]
fn scope_precedence_and_recursion() {
let f = Fixture::new();
let r = resolve(
"GET ${url:base}/search?q=${recordRef}",
&f.ctx(ResolveMode::Strict),
&mut 0,
)
.unwrap();
assert_eq!(r.text, "GET https://api.example/search?q=r-run-0001");
}
#[test]
fn runtime_tier_passes_through() {
let f = Fixture::new();
let r = resolve(
"Authorization: Bearer {{token}}",
&f.ctx(ResolveMode::Strict),
&mut 0,
)
.unwrap();
assert_eq!(r.text, "Authorization: Bearer {{token}}");
}
#[test]
fn escape_round_trips() {
let f = Fixture::new();
let r = resolve(
"literal $${notavar} stays",
&f.ctx(ResolveMode::Strict),
&mut 0,
)
.unwrap();
assert_eq!(r.text, "literal ${notavar} stays");
}
#[test]
fn env_defaults_apply() {
let f = Fixture::new();
let ctx = f.ctx(ResolveMode::Strict);
assert_eq!(
resolve("${env:HOME}", &ctx, &mut 0).unwrap().text,
"/home/test"
);
assert_eq!(
resolve("${env:NOPE:-fallback}", &ctx, &mut 0).unwrap().text,
"fallback"
);
let err = resolve("${env:NOPE}", &ctx, &mut 0).unwrap_err();
assert_eq!(err.code(), "proef::resolve::missing_env");
}
#[test]
fn secrets_become_runtime_placeholders_and_are_recorded() {
let f = Fixture::new();
let r = resolve(
"Bearer ${secret:apiToken}",
&f.ctx(ResolveMode::Strict),
&mut 0,
)
.unwrap();
assert_eq!(r.text, "Bearer {{apiToken}}");
assert!(r.secrets.contains("apiToken"));
}
#[test]
fn globals_read_from_the_world() {
let f = Fixture::new();
let r = resolve("id=${global:recordId}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap();
assert_eq!(r.text, "id=r-42");
}
#[test]
fn config_vars_resolve_from_the_injected_scope() {
let f = Fixture::new();
let r = resolve(
"${url:base}/v/${vars:apiVersion}",
&f.ctx(ResolveMode::Strict),
&mut 0,
)
.unwrap();
assert_eq!(r.text, "https://api.example/v/v1");
}
#[test]
fn missing_config_var_errors_in_strict_and_dry_run_but_probes() {
let f = Fixture::new();
let err = resolve("${url:admin}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
assert_eq!(err.code(), "proef::resolve::missing_config_var");
assert!(resolve("${vars:nope}", &f.ctx(ResolveMode::DryRun), &mut 0).is_err());
assert!(resolve("${url:admin}", &f.ctx(ResolveMode::Probe), &mut 0).is_ok());
}
#[test]
fn missing_config_var_suggests_the_closest_key_in_the_same_namespace() {
let f = Fixture::new();
let err = resolve("${url:bse}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
let message = err.to_string();
assert!(
message.contains("did you mean `base`"),
"expected a suggestion naming the near key, got: {message}"
);
}
#[test]
fn missing_config_var_never_suggests_across_namespaces() {
let f = Fixture::new();
let err = resolve("${url:nearvar}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
let message = err.to_string();
assert!(
!message.contains("did you mean"),
"suggestion crossed namespaces: {message}"
);
}
#[test]
fn missing_global_is_strict_error_but_dry_run_warning() {
let f = Fixture::new();
let err = resolve("${global:nope}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
assert_eq!(err.code(), "proef::resolve::missing_global");
let r = resolve("${global:nope}", &f.ctx(ResolveMode::DryRun), &mut 0).unwrap();
assert_eq!(r.text, "");
assert_eq!(r.warnings.len(), 1);
}
#[test]
fn unknown_variable_suggests_the_closest_name() {
let f = Fixture::new();
let err = resolve("${recordRe}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
let ResolveError::UnknownVariable { suggestion, .. } = &err else {
panic!("wrong variant: {err:?}");
};
assert_eq!(suggestion.as_deref(), Some("recordRef"));
}
#[test]
fn reference_cycles_hit_the_depth_cap() {
let mut f = Fixture::new();
f.args = map(&[("a", "${b}"), ("b", "${a}")]);
let err = resolve("${a}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
assert_eq!(err.code(), "proef::resolve::depth_exceeded");
}
#[test]
fn fakes_generate_deterministically_and_reject_typos() {
let f = Fixture::new();
let once = resolve(
"${fake:firstName} ${fake:firstName}",
&f.ctx(ResolveMode::Strict),
&mut 0,
)
.unwrap()
.text;
let twice = resolve(
"${fake:firstName} ${fake:firstName}",
&f.ctx(ResolveMode::Strict),
&mut 0,
)
.unwrap()
.text;
assert_eq!(once, twice, "deterministic per run id");
assert!(!once.trim().is_empty());
let err = resolve("${fake:firstNam}", &f.ctx(ResolveMode::Strict), &mut 0).unwrap_err();
assert_eq!(err.code(), "proef::resolve::fake_unknown");
assert!(err.to_string().contains("firstName"), "{err}");
assert!(resolve("${fake:firstNam}", &f.ctx(ResolveMode::Probe), &mut 0).is_err());
}
fn resolve_two_steps(a: &str, b: &str) -> (String, String) {
let f = Fixture::new();
let ctx = f.ctx(ResolveMode::Strict);
let mut fakes = 0;
let first = resolve(a, &ctx, &mut fakes).unwrap().text;
let second = resolve(b, &ctx, &mut fakes).unwrap().text;
(first, second)
}
#[test]
fn fake_values_do_not_collide_across_steps_in_a_scenario() {
let (first, second) = resolve_two_steps("${fake:email}", "${fake:email}");
assert_ne!(
first, second,
"two steps' fake values collided: {first} == {second}"
);
}
#[test]
fn fake_values_are_reproducible_for_the_same_run_id() {
let first_run = resolve_two_steps("${fake:email}", "${fake:email}");
let second_run = resolve_two_steps("${fake:email}", "${fake:email}");
assert_eq!(
first_run, second_run,
"same run id produced different fakes"
);
}
#[test]
fn unclosed_reference_is_literal() {
let f = Fixture::new();
let r = resolve("half ${open and done", &f.ctx(ResolveMode::Strict), &mut 0).unwrap();
assert_eq!(r.text, "half ${open and done");
}
mod properties {
#![allow(clippy::ignored_unit_patterns)]
use super::*;
use proptest::prelude::*;
fn empty_ctx_fixture() -> Fixture {
let mut f = Fixture::new();
f.args = BTreeMap::new();
f.defaults = BTreeMap::new();
f
}
proptest! {
#[test]
fn resolver_never_panics(text in ".{0,200}") {
let f = Fixture::new();
let _ = resolve(&text, &f.ctx(ResolveMode::DryRun), &mut 0);
}
#[test]
fn escape_round_trip(inner in "[^{}$]{0,40}") {
let f = empty_ctx_fixture();
let text = format!("$${{{inner}}}");
let resolved = resolve(&text, &f.ctx(ResolveMode::Strict), &mut 0).unwrap();
prop_assert_eq!(resolved.text, format!("${{{inner}}}"));
}
#[test]
fn idempotent_after_fixpoint(text in "[^$]{0,120}") {
let f = empty_ctx_fixture();
let ctx = f.ctx(ResolveMode::Strict);
let once = resolve(&text, &ctx, &mut 0).unwrap();
let twice = resolve(&once.text, &ctx, &mut 0).unwrap();
prop_assert_eq!(&once.text, &twice.text);
}
#[test]
fn always_terminates(
keys in proptest::collection::vec("[a-c]{1}", 1..3),
text in "[a-c${}]{0,60}",
) {
let mut f = empty_ctx_fixture();
f.args = keys.iter().map(|k| (k.clone(), format!("${{{k}}}"))).collect();
let _ = resolve(&text, &f.ctx(ResolveMode::DryRun), &mut 0);
}
}
}
}