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