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 env: &'a BTreeMap<String, String>,
48 pub config_vars: &'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 warnings: Vec<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
75pub enum ResolveError {
76 #[error("unknown variable `${{{name}}}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
78 UnknownVariable {
79 name: String,
81 suggestion: Option<String>,
83 },
84 #[error(
86 "environment variable `{name}` is not set (use `${{env:{name}:-default}}` for a fallback)"
87 )]
88 MissingEnv {
89 name: String,
91 },
92 #[error("global `{key}` is not set in the World")]
94 MissingGlobal {
95 key: String,
97 },
98 #[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 namespace: String,
107 key: String,
109 suggestion: Option<String>,
111 },
112 #[error(
114 "unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake, url, vars)"
115 )]
116 UnknownNamespace {
117 namespace: String,
119 },
120 #[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
122 UnknownRunField {
123 field: String,
125 },
126 #[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
128 FakeUnknown {
129 kind: String,
131 suggestion: Option<String>,
133 },
134 #[error("empty variable reference `${{}}`")]
136 EmptyReference,
137 #[error(
139 "variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
140 )]
141 DepthExceeded {
142 name: String,
144 },
145}
146
147impl ResolveError {
148 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
164pub 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(¤t, ctx, &mut resolution, fakes)?;
183 current = next;
184 if !substituted {
185 resolution.text = unescape(¤t);
186 return Ok(resolution);
187 }
188 }
189
190 if let Some((name, _, _)) = first_reference(¤t) {
191 Err(ResolveError::DepthExceeded {
192 name: name.to_owned(),
193 })
194 } else {
195 resolution.text = unescape(¤t);
196 Ok(resolution)
197 }
198}
199
200fn 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
223pub(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 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 return None;
243 }
244 }
245 i += 1;
246 }
247 None
248}
249
250fn 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 Ok(format!("{{{{{arg}}}}}"))
314 }
315 "fake" => {
316 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 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
361fn 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 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
395fn 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
405fn 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 ("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 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 assert!(resolve("${vars:nope}", &f.ctx(ResolveMode::DryRun), &mut 0).is_err());
557 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 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 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 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 assert!(resolve("${fake:firstNam}", &f.ctx(ResolveMode::Probe), &mut 0).is_err());
644 }
645
646 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 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 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 #[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 #[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 #[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 #[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 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}