1use std::collections::{BTreeMap, BTreeSet};
17
18use crate::world::World;
19
20pub const MAX_DEPTH: usize = 8;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ResolveMode {
26 Strict,
28 DryRun,
31 Probe,
36}
37
38#[derive(Debug, Clone, Copy)]
41pub struct ResolveCtx<'a> {
42 pub args: &'a BTreeMap<String, String>,
44 pub defaults: &'a BTreeMap<String, String>,
46 pub directives: &'a BTreeMap<String, String>,
48 pub config: &'a BTreeMap<String, String>,
50 pub env: &'a BTreeMap<String, String>,
52 pub run_id: &'a str,
54 pub world: &'a World,
56 pub mode: ResolveMode,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct Resolution {
63 pub text: String,
65 pub secrets: BTreeSet<String>,
67 pub globals: BTreeSet<String>,
69 pub fakes: usize,
71 pub warnings: Vec<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
77pub enum ResolveError {
78 #[error("unknown variable `${{{name}}}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
80 UnknownVariable {
81 name: String,
83 suggestion: Option<String>,
85 },
86 #[error(
88 "environment variable `{name}` is not set (use `${{env:{name}:-default}}` for a fallback)"
89 )]
90 MissingEnv {
91 name: String,
93 },
94 #[error("global `{key}` is not set in the World")]
96 MissingGlobal {
97 key: String,
99 },
100 #[error("unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake)")]
102 UnknownNamespace {
103 namespace: String,
105 },
106 #[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
108 UnknownRunField {
109 field: String,
111 },
112 #[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
114 FakeUnknown {
115 kind: String,
117 suggestion: Option<String>,
119 },
120 #[error("empty variable reference `${{}}`")]
122 EmptyReference,
123 #[error(
125 "variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
126 )]
127 DepthExceeded {
128 name: String,
130 },
131}
132
133impl ResolveError {
134 pub fn code(&self) -> &'static str {
136 match self {
137 Self::UnknownVariable { .. } => "proef::resolve::unknown_variable",
138 Self::MissingEnv { .. } => "proef::resolve::missing_env",
139 Self::MissingGlobal { .. } => "proef::resolve::missing_global",
140 Self::UnknownNamespace { .. } => "proef::resolve::unknown_namespace",
141 Self::UnknownRunField { .. } => "proef::resolve::unknown_run_field",
142 Self::FakeUnknown { .. } => "proef::resolve::fake_unknown",
143 Self::EmptyReference => "proef::resolve::empty_reference",
144 Self::DepthExceeded { .. } => "proef::resolve::depth_exceeded",
145 }
146 }
147}
148
149pub fn resolve(text: &str, ctx: &ResolveCtx<'_>) -> Result<Resolution, ResolveError> {
152 let mut resolution = Resolution::default();
153 let mut current = text.to_owned();
154
155 for _ in 0..MAX_DEPTH {
156 let (next, substituted) = resolve_pass(¤t, ctx, &mut resolution)?;
157 current = next;
158 if !substituted {
159 resolution.text = unescape(¤t);
160 return Ok(resolution);
161 }
162 }
163
164 if let Some((name, _, _)) = first_reference(¤t) {
165 Err(ResolveError::DepthExceeded {
166 name: name.to_owned(),
167 })
168 } else {
169 resolution.text = unescape(¤t);
170 Ok(resolution)
171 }
172}
173
174fn resolve_pass(
177 text: &str,
178 ctx: &ResolveCtx<'_>,
179 resolution: &mut Resolution,
180) -> Result<(String, bool), ResolveError> {
181 let mut out = String::with_capacity(text.len());
182 let mut rest = text;
183 let mut substituted = false;
184
185 while let Some((name, start, end)) = first_reference(rest) {
186 out.push_str(&rest[..start]);
187 let value = lookup(name, ctx, resolution)?;
188 out.push_str(&value);
189 substituted = true;
190 rest = &rest[end..];
191 }
192 out.push_str(rest);
193 Ok((out, substituted))
194}
195
196fn first_reference(text: &str) -> Option<(&str, usize, usize)> {
199 let bytes = text.as_bytes();
200 let mut i = 0;
201 while i < bytes.len() {
202 if bytes[i] == b'$' {
203 if text[i..].starts_with("$${") {
205 i += 3;
206 continue;
207 }
208 if text[i..].starts_with("${") {
209 let after = &text[i + 2..];
210 if let Some(close) = after.find('}') {
211 let name = &after[..close];
212 return Some((name, i, i + 2 + close + 1));
213 }
214 return None;
216 }
217 }
218 i += 1;
219 }
220 None
221}
222
223fn lookup(
225 name: &str,
226 ctx: &ResolveCtx<'_>,
227 resolution: &mut Resolution,
228) -> Result<String, ResolveError> {
229 let name = name.trim();
230 if name.is_empty() {
231 return Err(ResolveError::EmptyReference);
232 }
233
234 if let Some((namespace, arg)) = name.split_once(':') {
235 return match namespace {
236 "env" => {
237 let (var, default) = match arg.split_once(":-") {
238 Some((var, default)) => (var, Some(default)),
239 None => (arg, None),
240 };
241 match ctx.env.get(var) {
242 Some(value) => Ok(value.clone()),
243 None => match default {
244 Some(default) => Ok(default.to_owned()),
245 None => probe_or(
246 ResolveError::MissingEnv {
247 name: var.to_owned(),
248 },
249 ctx.mode,
250 ),
251 },
252 }
253 }
254 "run" => {
255 if arg == "id" {
256 Ok(ctx.run_id.to_owned())
257 } else {
258 Err(ResolveError::UnknownRunField {
259 field: arg.to_owned(),
260 })
261 }
262 }
263 "global" => {
264 resolution.globals.insert(arg.to_owned());
265 match ctx.world.get(arg) {
266 Some(value) => Ok(value.to_string()),
267 None => match ctx.mode {
268 ResolveMode::Strict => Err(ResolveError::MissingGlobal {
269 key: arg.to_owned(),
270 }),
271 ResolveMode::DryRun => {
272 resolution.warnings.push(format!(
273 "`${{global:{arg}}}` is not set yet — it may be populated at run time"
274 ));
275 Ok(String::new())
276 }
277 ResolveMode::Probe => Ok("probe".to_owned()),
278 },
279 }
280 }
281 "secret" => {
282 resolution.secrets.insert(arg.to_owned());
283 Ok(format!("{{{{{arg}}}}}"))
286 }
287 "fake" => {
288 if !crate::fake::is_known_generator(arg) {
291 return Err(ResolveError::FakeUnknown {
292 kind: arg.to_owned(),
293 suggestion: crate::matcher::closest(
294 arg,
295 crate::fake::GENERATORS.iter().copied(),
296 )
297 .map(ToOwned::to_owned),
298 });
299 }
300 let occurrence = resolution.fakes;
301 resolution.fakes += 1;
302 crate::fake::generate(ctx.run_id, occurrence, arg).ok_or_else(|| {
303 ResolveError::FakeUnknown {
304 kind: arg.to_owned(),
305 suggestion: None,
306 }
307 })
308 }
309 other => Err(ResolveError::UnknownNamespace {
310 namespace: other.to_owned(),
311 }),
312 };
313 }
314
315 for scope in [ctx.args, ctx.defaults, ctx.directives, ctx.config] {
317 if let Some(value) = scope.get(name) {
318 return Ok(value.clone());
319 }
320 }
321 let known = ctx
322 .args
323 .keys()
324 .chain(ctx.defaults.keys())
325 .chain(ctx.directives.keys())
326 .chain(ctx.config.keys());
327 probe_or(
328 ResolveError::UnknownVariable {
329 name: name.to_owned(),
330 suggestion: crate::matcher::closest(name, known.map(String::as_str))
331 .map(ToOwned::to_owned),
332 },
333 ctx.mode,
334 )
335}
336
337fn probe_or(err: ResolveError, mode: ResolveMode) -> Result<String, ResolveError> {
340 if mode == ResolveMode::Probe {
341 Ok("probe".to_owned())
342 } else {
343 Err(err)
344 }
345}
346
347fn unescape(text: &str) -> String {
349 text.replace("$${", "${")
350}
351
352#[cfg(test)]
353mod tests {
354 #![allow(clippy::unwrap_used)]
355
356 use super::*;
357 use crate::world::{GlobalStore, Value};
358
359 fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
360 pairs
361 .iter()
362 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
363 .collect()
364 }
365
366 struct Fixture {
367 args: BTreeMap<String, String>,
368 defaults: BTreeMap<String, String>,
369 directives: BTreeMap<String, String>,
370 config: BTreeMap<String, String>,
371 env: BTreeMap<String, String>,
372 world: World,
373 }
374
375 impl Fixture {
376 fn new() -> Self {
377 let mut store = GlobalStore::new();
378 store.insert("clientId", Value::String("c-42".into()));
379 Self {
380 args: map(&[("lastName", "Bakker-${run:id}")]),
381 defaults: map(&[("index", "clients")]),
382 directives: map(&[("baseURL", "http://fixture.local")]),
383 config: BTreeMap::new(),
384 env: map(&[("HOME", "/home/test")]),
385 world: World::new(store),
386 }
387 }
388
389 fn ctx(&self, mode: ResolveMode) -> ResolveCtx<'_> {
390 ResolveCtx {
391 args: &self.args,
392 defaults: &self.defaults,
393 directives: &self.directives,
394 config: &self.config,
395 env: &self.env,
396 run_id: "run-0001",
397 world: &self.world,
398 mode,
399 }
400 }
401 }
402
403 #[test]
404 fn scope_precedence_and_recursion() {
405 let f = Fixture::new();
406 let r = resolve(
408 "GET ${baseURL}/search?q=${lastName}",
409 &f.ctx(ResolveMode::Strict),
410 )
411 .unwrap();
412 assert_eq!(r.text, "GET http://fixture.local/search?q=Bakker-run-0001");
413 }
414
415 #[test]
416 fn runtime_tier_passes_through() {
417 let f = Fixture::new();
418 let r = resolve(
419 "Authorization: Bearer {{token}}",
420 &f.ctx(ResolveMode::Strict),
421 )
422 .unwrap();
423 assert_eq!(r.text, "Authorization: Bearer {{token}}");
424 }
425
426 #[test]
427 fn escape_round_trips() {
428 let f = Fixture::new();
429 let r = resolve("literal $${notavar} stays", &f.ctx(ResolveMode::Strict)).unwrap();
430 assert_eq!(r.text, "literal ${notavar} stays");
431 }
432
433 #[test]
434 fn env_defaults_apply() {
435 let f = Fixture::new();
436 let ctx = f.ctx(ResolveMode::Strict);
437 assert_eq!(resolve("${env:HOME}", &ctx).unwrap().text, "/home/test");
438 assert_eq!(
439 resolve("${env:NOPE:-fallback}", &ctx).unwrap().text,
440 "fallback"
441 );
442 let err = resolve("${env:NOPE}", &ctx).unwrap_err();
443 assert_eq!(err.code(), "proef::resolve::missing_env");
444 }
445
446 #[test]
447 fn secrets_become_runtime_placeholders_and_are_recorded() {
448 let f = Fixture::new();
449 let r = resolve("Bearer ${secret:apiToken}", &f.ctx(ResolveMode::Strict)).unwrap();
450 assert_eq!(r.text, "Bearer {{apiToken}}");
451 assert!(r.secrets.contains("apiToken"));
452 }
453
454 #[test]
455 fn globals_read_from_the_world() {
456 let f = Fixture::new();
457 let r = resolve("id=${global:clientId}", &f.ctx(ResolveMode::Strict)).unwrap();
458 assert_eq!(r.text, "id=c-42");
459 }
460
461 #[test]
462 fn missing_global_is_strict_error_but_dry_run_warning() {
463 let f = Fixture::new();
464 let err = resolve("${global:nope}", &f.ctx(ResolveMode::Strict)).unwrap_err();
465 assert_eq!(err.code(), "proef::resolve::missing_global");
466
467 let r = resolve("${global:nope}", &f.ctx(ResolveMode::DryRun)).unwrap();
468 assert_eq!(r.text, "");
469 assert_eq!(r.warnings.len(), 1);
470 }
471
472 #[test]
473 fn unknown_variable_suggests_the_closest_name() {
474 let f = Fixture::new();
475 let err = resolve("${lastNam}", &f.ctx(ResolveMode::Strict)).unwrap_err();
476 let ResolveError::UnknownVariable { suggestion, .. } = &err else {
477 panic!("wrong variant: {err:?}");
478 };
479 assert_eq!(suggestion.as_deref(), Some("lastName"));
480 }
481
482 #[test]
483 fn reference_cycles_hit_the_depth_cap() {
484 let mut f = Fixture::new();
485 f.args = map(&[("a", "${b}"), ("b", "${a}")]);
486 let err = resolve("${a}", &f.ctx(ResolveMode::Strict)).unwrap_err();
487 assert_eq!(err.code(), "proef::resolve::depth_exceeded");
488 }
489
490 #[test]
491 fn fakes_generate_deterministically_and_reject_typos() {
492 let f = Fixture::new();
493 let once = resolve(
494 "${fake:firstName} ${fake:firstName}",
495 &f.ctx(ResolveMode::Strict),
496 )
497 .unwrap()
498 .text;
499 let twice = resolve(
500 "${fake:firstName} ${fake:firstName}",
501 &f.ctx(ResolveMode::Strict),
502 )
503 .unwrap()
504 .text;
505 assert_eq!(once, twice, "deterministic per run id");
506 assert!(!once.trim().is_empty());
507
508 let err = resolve("${fake:firstNam}", &f.ctx(ResolveMode::Strict)).unwrap_err();
509 assert_eq!(err.code(), "proef::resolve::fake_unknown");
510 assert!(err.to_string().contains("firstName"), "{err}");
511 assert!(resolve("${fake:firstNam}", &f.ctx(ResolveMode::Probe)).is_err());
513 }
514
515 #[test]
516 fn unclosed_reference_is_literal() {
517 let f = Fixture::new();
518 let r = resolve("half ${open and done", &f.ctx(ResolveMode::Strict)).unwrap();
519 assert_eq!(r.text, "half ${open and done");
520 }
521
522 mod properties {
523 #![allow(clippy::ignored_unit_patterns)]
524
525 use super::*;
526 use proptest::prelude::*;
527
528 fn empty_ctx_fixture() -> Fixture {
529 let mut f = Fixture::new();
530 f.args = BTreeMap::new();
531 f.defaults = BTreeMap::new();
532 f.directives = BTreeMap::new();
533 f
534 }
535
536 proptest! {
537 #[test]
539 fn resolver_never_panics(text in ".{0,200}") {
540 let f = Fixture::new();
541 let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
542 }
543
544 #[test]
546 fn escape_round_trip(inner in "[^{}$]{0,40}") {
547 let f = empty_ctx_fixture();
548 let text = format!("$${{{inner}}}");
549 let resolved = resolve(&text, &f.ctx(ResolveMode::Strict)).unwrap();
550 prop_assert_eq!(resolved.text, format!("${{{inner}}}"));
551 }
552
553 #[test]
555 fn idempotent_after_fixpoint(text in "[^$]{0,120}") {
556 let f = empty_ctx_fixture();
557 let ctx = f.ctx(ResolveMode::Strict);
558 let once = resolve(&text, &ctx).unwrap();
559 let twice = resolve(&once.text, &ctx).unwrap();
560 prop_assert_eq!(&once.text, &twice.text);
561 }
562
563 #[test]
565 fn always_terminates(
566 keys in proptest::collection::vec("[a-c]{1}", 1..3),
567 text in "[a-c${}]{0,60}",
568 ) {
569 let mut f = empty_ctx_fixture();
570 f.args = keys.iter().map(|k| (k.clone(), format!("${{{k}}}"))).collect();
572 let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
573 }
574 }
575 }
576}