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 )]
105 MissingConfigVar {
106 namespace: String,
108 key: String,
110 },
111 #[error(
113 "unknown variable namespace `{namespace}:` (known: env, run, global, secret, fake, url, vars)"
114 )]
115 UnknownNamespace {
116 namespace: String,
118 },
119 #[error("unknown run field `{field}` (only `${{run:id}}` exists)")]
121 UnknownRunField {
122 field: String,
124 },
125 #[error("unknown fake generator `{kind}`{}", suggestion.as_ref().map(|s| format!(" — did you mean `{s}`?")).unwrap_or_default())]
127 FakeUnknown {
128 kind: String,
130 suggestion: Option<String>,
132 },
133 #[error("empty variable reference `${{}}`")]
135 EmptyReference,
136 #[error(
138 "variable resolution exceeded depth {MAX_DEPTH} (reference cycle through `${{{name}}}`?)"
139 )]
140 DepthExceeded {
141 name: String,
143 },
144}
145
146impl ResolveError {
147 pub fn code(&self) -> &'static str {
149 match self {
150 Self::UnknownVariable { .. } => "proef::resolve::unknown_variable",
151 Self::MissingEnv { .. } => "proef::resolve::missing_env",
152 Self::MissingConfigVar { .. } => "proef::resolve::missing_config_var",
153 Self::MissingGlobal { .. } => "proef::resolve::missing_global",
154 Self::UnknownNamespace { .. } => "proef::resolve::unknown_namespace",
155 Self::UnknownRunField { .. } => "proef::resolve::unknown_run_field",
156 Self::FakeUnknown { .. } => "proef::resolve::fake_unknown",
157 Self::EmptyReference => "proef::resolve::empty_reference",
158 Self::DepthExceeded { .. } => "proef::resolve::depth_exceeded",
159 }
160 }
161}
162
163pub fn resolve(text: &str, ctx: &ResolveCtx<'_>) -> Result<Resolution, ResolveError> {
166 let mut resolution = Resolution::default();
167 let mut current = text.to_owned();
168
169 for _ in 0..MAX_DEPTH {
170 let (next, substituted) = resolve_pass(¤t, ctx, &mut resolution)?;
171 current = next;
172 if !substituted {
173 resolution.text = unescape(¤t);
174 return Ok(resolution);
175 }
176 }
177
178 if let Some((name, _, _)) = first_reference(¤t) {
179 Err(ResolveError::DepthExceeded {
180 name: name.to_owned(),
181 })
182 } else {
183 resolution.text = unescape(¤t);
184 Ok(resolution)
185 }
186}
187
188fn resolve_pass(
191 text: &str,
192 ctx: &ResolveCtx<'_>,
193 resolution: &mut Resolution,
194) -> Result<(String, bool), ResolveError> {
195 let mut out = String::with_capacity(text.len());
196 let mut rest = text;
197 let mut substituted = false;
198
199 while let Some((name, start, end)) = first_reference(rest) {
200 out.push_str(&rest[..start]);
201 let value = lookup(name, ctx, resolution)?;
202 out.push_str(&value);
203 substituted = true;
204 rest = &rest[end..];
205 }
206 out.push_str(rest);
207 Ok((out, substituted))
208}
209
210fn first_reference(text: &str) -> Option<(&str, usize, usize)> {
213 let bytes = text.as_bytes();
214 let mut i = 0;
215 while i < bytes.len() {
216 if bytes[i] == b'$' {
217 if text[i..].starts_with("$${") {
219 i += 3;
220 continue;
221 }
222 if text[i..].starts_with("${") {
223 let after = &text[i + 2..];
224 if let Some(close) = after.find('}') {
225 let name = &after[..close];
226 return Some((name, i, i + 2 + close + 1));
227 }
228 return None;
230 }
231 }
232 i += 1;
233 }
234 None
235}
236
237fn lookup(
239 name: &str,
240 ctx: &ResolveCtx<'_>,
241 resolution: &mut Resolution,
242) -> Result<String, ResolveError> {
243 let name = name.trim();
244 if name.is_empty() {
245 return Err(ResolveError::EmptyReference);
246 }
247
248 if let Some((namespace, arg)) = name.split_once(':') {
249 return match namespace {
250 "env" => {
251 let (var, default) = match arg.split_once(":-") {
252 Some((var, default)) => (var, Some(default)),
253 None => (arg, None),
254 };
255 match ctx.env.get(var) {
256 Some(value) => Ok(value.clone()),
257 None => match default {
258 Some(default) => Ok(default.to_owned()),
259 None => probe_or(
260 ResolveError::MissingEnv {
261 name: var.to_owned(),
262 },
263 ctx.mode,
264 ),
265 },
266 }
267 }
268 "run" => {
269 if arg == "id" {
270 Ok(ctx.run_id.to_owned())
271 } else {
272 Err(ResolveError::UnknownRunField {
273 field: arg.to_owned(),
274 })
275 }
276 }
277 "global" => {
278 resolution.globals.insert(arg.to_owned());
279 match ctx.world.get(arg) {
280 Some(value) => Ok(value.to_string()),
281 None => match ctx.mode {
282 ResolveMode::Strict => Err(ResolveError::MissingGlobal {
283 key: arg.to_owned(),
284 }),
285 ResolveMode::DryRun => {
286 resolution.warnings.push(format!(
287 "`${{global:{arg}}}` is not set yet — it may be populated at run time"
288 ));
289 Ok(String::new())
290 }
291 ResolveMode::Probe => Ok("probe".to_owned()),
292 },
293 }
294 }
295 "secret" => {
296 resolution.secrets.insert(arg.to_owned());
297 Ok(format!("{{{{{arg}}}}}"))
300 }
301 "fake" => {
302 if !crate::fake::is_known_generator(arg) {
305 return Err(ResolveError::FakeUnknown {
306 kind: arg.to_owned(),
307 suggestion: crate::matcher::closest(
308 arg,
309 crate::fake::GENERATORS.iter().copied(),
310 )
311 .map(ToOwned::to_owned),
312 });
313 }
314 let occurrence = resolution.fakes;
315 resolution.fakes += 1;
316 crate::fake::generate(ctx.run_id, occurrence, arg).ok_or_else(|| {
317 ResolveError::FakeUnknown {
318 kind: arg.to_owned(),
319 suggestion: None,
320 }
321 })
322 }
323 "url" | "vars" => resolve_config_var(name, namespace, arg, ctx),
324 other => Err(ResolveError::UnknownNamespace {
325 namespace: other.to_owned(),
326 }),
327 };
328 }
329
330 for scope in [ctx.args, ctx.defaults] {
332 if let Some(value) = scope.get(name) {
333 return Ok(value.clone());
334 }
335 }
336 let known = ctx.args.keys().chain(ctx.defaults.keys());
337 probe_or(
338 ResolveError::UnknownVariable {
339 name: name.to_owned(),
340 suggestion: crate::matcher::closest(name, known.map(String::as_str))
341 .map(ToOwned::to_owned),
342 },
343 ctx.mode,
344 )
345}
346
347fn resolve_config_var(
353 name: &str,
354 namespace: &str,
355 arg: &str,
356 ctx: &ResolveCtx<'_>,
357) -> Result<String, ResolveError> {
358 match ctx.config_vars.get(name) {
359 Some(value) => Ok(value.clone()),
360 None => probe_or(
361 ResolveError::MissingConfigVar {
362 namespace: namespace.to_owned(),
363 key: arg.to_owned(),
364 },
365 ctx.mode,
366 ),
367 }
368}
369
370fn probe_or(err: ResolveError, mode: ResolveMode) -> Result<String, ResolveError> {
373 if mode == ResolveMode::Probe {
374 Ok("probe".to_owned())
375 } else {
376 Err(err)
377 }
378}
379
380fn unescape(text: &str) -> String {
382 text.replace("$${", "${")
383}
384
385#[cfg(test)]
386mod tests {
387 #![allow(clippy::unwrap_used)]
388
389 use super::*;
390 use crate::world::{GlobalStore, Value};
391
392 fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
393 pairs
394 .iter()
395 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
396 .collect()
397 }
398
399 struct Fixture {
400 args: BTreeMap<String, String>,
401 defaults: BTreeMap<String, String>,
402 env: BTreeMap<String, String>,
403 config_vars: BTreeMap<String, String>,
404 world: World,
405 }
406
407 impl Fixture {
408 fn new() -> Self {
409 let mut store = GlobalStore::new();
410 store.insert("recordId", Value::String("r-42".into()));
411 Self {
412 args: map(&[("recordRef", "r-${run:id}")]),
413 defaults: map(&[("index", "records")]),
414 env: map(&[("HOME", "/home/test")]),
415 config_vars: map(&[
416 ("url:base", "https://api.example"),
417 ("vars:apiVersion", "v1"),
418 ]),
419 world: World::new(store),
420 }
421 }
422
423 fn ctx(&self, mode: ResolveMode) -> ResolveCtx<'_> {
424 ResolveCtx {
425 args: &self.args,
426 defaults: &self.defaults,
427 env: &self.env,
428 config_vars: &self.config_vars,
429 run_id: "run-0001",
430 world: &self.world,
431 mode,
432 }
433 }
434 }
435
436 #[test]
437 fn scope_precedence_and_recursion() {
438 let f = Fixture::new();
439 let r = resolve(
441 "GET ${url:base}/search?q=${recordRef}",
442 &f.ctx(ResolveMode::Strict),
443 )
444 .unwrap();
445 assert_eq!(r.text, "GET https://api.example/search?q=r-run-0001");
446 }
447
448 #[test]
449 fn runtime_tier_passes_through() {
450 let f = Fixture::new();
451 let r = resolve(
452 "Authorization: Bearer {{token}}",
453 &f.ctx(ResolveMode::Strict),
454 )
455 .unwrap();
456 assert_eq!(r.text, "Authorization: Bearer {{token}}");
457 }
458
459 #[test]
460 fn escape_round_trips() {
461 let f = Fixture::new();
462 let r = resolve("literal $${notavar} stays", &f.ctx(ResolveMode::Strict)).unwrap();
463 assert_eq!(r.text, "literal ${notavar} stays");
464 }
465
466 #[test]
467 fn env_defaults_apply() {
468 let f = Fixture::new();
469 let ctx = f.ctx(ResolveMode::Strict);
470 assert_eq!(resolve("${env:HOME}", &ctx).unwrap().text, "/home/test");
471 assert_eq!(
472 resolve("${env:NOPE:-fallback}", &ctx).unwrap().text,
473 "fallback"
474 );
475 let err = resolve("${env:NOPE}", &ctx).unwrap_err();
476 assert_eq!(err.code(), "proef::resolve::missing_env");
477 }
478
479 #[test]
480 fn secrets_become_runtime_placeholders_and_are_recorded() {
481 let f = Fixture::new();
482 let r = resolve("Bearer ${secret:apiToken}", &f.ctx(ResolveMode::Strict)).unwrap();
483 assert_eq!(r.text, "Bearer {{apiToken}}");
484 assert!(r.secrets.contains("apiToken"));
485 }
486
487 #[test]
488 fn globals_read_from_the_world() {
489 let f = Fixture::new();
490 let r = resolve("id=${global:recordId}", &f.ctx(ResolveMode::Strict)).unwrap();
491 assert_eq!(r.text, "id=r-42");
492 }
493
494 #[test]
495 fn config_vars_resolve_from_the_injected_scope() {
496 let f = Fixture::new();
497 let r = resolve(
498 "${url:base}/v/${vars:apiVersion}",
499 &f.ctx(ResolveMode::Strict),
500 )
501 .unwrap();
502 assert_eq!(r.text, "https://api.example/v/v1");
503 }
504
505 #[test]
506 fn missing_config_var_errors_in_strict_and_dry_run_but_probes() {
507 let f = Fixture::new();
508 let err = resolve("${url:admin}", &f.ctx(ResolveMode::Strict)).unwrap_err();
509 assert_eq!(err.code(), "proef::resolve::missing_config_var");
510 assert!(resolve("${vars:nope}", &f.ctx(ResolveMode::DryRun)).is_err());
512 assert!(resolve("${url:admin}", &f.ctx(ResolveMode::Probe)).is_ok());
514 }
515
516 #[test]
517 fn missing_global_is_strict_error_but_dry_run_warning() {
518 let f = Fixture::new();
519 let err = resolve("${global:nope}", &f.ctx(ResolveMode::Strict)).unwrap_err();
520 assert_eq!(err.code(), "proef::resolve::missing_global");
521
522 let r = resolve("${global:nope}", &f.ctx(ResolveMode::DryRun)).unwrap();
523 assert_eq!(r.text, "");
524 assert_eq!(r.warnings.len(), 1);
525 }
526
527 #[test]
528 fn unknown_variable_suggests_the_closest_name() {
529 let f = Fixture::new();
530 let err = resolve("${recordRe}", &f.ctx(ResolveMode::Strict)).unwrap_err();
531 let ResolveError::UnknownVariable { suggestion, .. } = &err else {
532 panic!("wrong variant: {err:?}");
533 };
534 assert_eq!(suggestion.as_deref(), Some("recordRef"));
535 }
536
537 #[test]
538 fn reference_cycles_hit_the_depth_cap() {
539 let mut f = Fixture::new();
540 f.args = map(&[("a", "${b}"), ("b", "${a}")]);
541 let err = resolve("${a}", &f.ctx(ResolveMode::Strict)).unwrap_err();
542 assert_eq!(err.code(), "proef::resolve::depth_exceeded");
543 }
544
545 #[test]
546 fn fakes_generate_deterministically_and_reject_typos() {
547 let f = Fixture::new();
548 let once = resolve(
549 "${fake:firstName} ${fake:firstName}",
550 &f.ctx(ResolveMode::Strict),
551 )
552 .unwrap()
553 .text;
554 let twice = resolve(
555 "${fake:firstName} ${fake:firstName}",
556 &f.ctx(ResolveMode::Strict),
557 )
558 .unwrap()
559 .text;
560 assert_eq!(once, twice, "deterministic per run id");
561 assert!(!once.trim().is_empty());
562
563 let err = resolve("${fake:firstNam}", &f.ctx(ResolveMode::Strict)).unwrap_err();
564 assert_eq!(err.code(), "proef::resolve::fake_unknown");
565 assert!(err.to_string().contains("firstName"), "{err}");
566 assert!(resolve("${fake:firstNam}", &f.ctx(ResolveMode::Probe)).is_err());
568 }
569
570 #[test]
571 fn unclosed_reference_is_literal() {
572 let f = Fixture::new();
573 let r = resolve("half ${open and done", &f.ctx(ResolveMode::Strict)).unwrap();
574 assert_eq!(r.text, "half ${open and done");
575 }
576
577 mod properties {
578 #![allow(clippy::ignored_unit_patterns)]
579
580 use super::*;
581 use proptest::prelude::*;
582
583 fn empty_ctx_fixture() -> Fixture {
584 let mut f = Fixture::new();
585 f.args = BTreeMap::new();
586 f.defaults = BTreeMap::new();
587 f
588 }
589
590 proptest! {
591 #[test]
593 fn resolver_never_panics(text in ".{0,200}") {
594 let f = Fixture::new();
595 let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
596 }
597
598 #[test]
600 fn escape_round_trip(inner in "[^{}$]{0,40}") {
601 let f = empty_ctx_fixture();
602 let text = format!("$${{{inner}}}");
603 let resolved = resolve(&text, &f.ctx(ResolveMode::Strict)).unwrap();
604 prop_assert_eq!(resolved.text, format!("${{{inner}}}"));
605 }
606
607 #[test]
609 fn idempotent_after_fixpoint(text in "[^$]{0,120}") {
610 let f = empty_ctx_fixture();
611 let ctx = f.ctx(ResolveMode::Strict);
612 let once = resolve(&text, &ctx).unwrap();
613 let twice = resolve(&once.text, &ctx).unwrap();
614 prop_assert_eq!(&once.text, &twice.text);
615 }
616
617 #[test]
619 fn always_terminates(
620 keys in proptest::collection::vec("[a-c]{1}", 1..3),
621 text in "[a-c${}]{0,60}",
622 ) {
623 let mut f = empty_ctx_fixture();
624 f.args = keys.iter().map(|k| (k.clone(), format!("${{{k}}}"))).collect();
626 let _ = resolve(&text, &f.ctx(ResolveMode::DryRun));
627 }
628 }
629 }
630}