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 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(
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 namespace: String,
109 key: String,
111 suggestion: Option<String>,
113 },
114 #[error(
116 "unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake, url, vars)"
117 )]
118 UnknownNamespace {
119 namespace: String,
121 },
122 #[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
124 UnknownRunField {
125 field: String,
127 },
128 #[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
130 FakeUnknown {
131 kind: String,
133 suggestion: Option<String>,
135 },
136 #[error("empty variable reference `${{}}`")]
138 EmptyReference,
139 #[error(
141 "variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
142 )]
143 DepthExceeded {
144 name: String,
146 },
147}
148
149impl ResolveError {
150 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
166pub 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(¤t, ctx, &mut resolution)?;
174 current = next;
175 if !substituted {
176 resolution.text = unescape(¤t);
177 return Ok(resolution);
178 }
179 }
180
181 if let Some((name, _, _)) = first_reference(¤t) {
182 Err(ResolveError::DepthExceeded {
183 name: name.to_owned(),
184 })
185 } else {
186 resolution.text = unescape(¤t);
187 Ok(resolution)
188 }
189}
190
191fn 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
213fn 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 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 return None;
233 }
234 }
235 i += 1;
236 }
237 None
238}
239
240fn 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 Ok(format!("{{{{{arg}}}}}"))
303 }
304 "fake" => {
305 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 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
350fn 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 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
384fn 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
394fn 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 ("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 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 assert!(resolve("${vars:nope}", &f.ctx(ResolveMode::DryRun)).is_err());
530 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 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 let f = Fixture::new();
551 let err = resolve("${url:nearvar}", &f.ctx(ResolveMode::Strict)).unwrap_err();
552 let message = err.to_string();
553 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 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 #[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 #[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 #[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 #[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 f.args = keys.iter().map(|k| (k.clone(), format!("${{{k}}}"))).collect();
673 let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
674 }
675 }
676 }
677}