Skip to content

Commit d8d142d

Browse files
committed
feat: template fallback operator ${expr || 'default'} (#2551)
## Motivation Strategy field names and descriptions in `.rain` YAML reference token properties via templates like `\${order.inputs.0.token.symbol} per \${order.outputs.0.token.symbol}`. The webapp resolves these at runtime once tokens have been selected. Any other consumer (notably `--describe`) that renders these strings before a token is picked sees the raw placeholder, which is confusing. Dropping the placeholder in un-selected contexts would lose the structural information. Hard-coding a substitution in every consumer is brittle and doesn't match webapp semantics. ## Solution Extend the template syntax with an optional fallback literal: ``` ${path} — current behaviour ${path || 'fallback'} — substitute literal when the path resolves to a missing token ${path || "fallback"} — double quotes also accepted ``` The fallback only kicks in on `PropertyNotFound("token")` (i.e. select-token not yet selected). Other resolution errors still propagate. Templates without `||` behave identically to today — backwards compatible. The webapp gets no regression: when tokens are selected the left-hand side always resolves and the fallback never fires. `--describe` (and any other "no context" consumer) gets readable output for free once registry strategies adopt fallbacks. Registry adoption is tracked in ST0x-Technology/st0x.registry#9. ## Checks - [x] made this PR as small as possible - [x] unit-tested any new functionality - [x] linked any relevant issues or PRs - [ ] included screenshots (if this involves a front-end change)
1 parent 545edb8 commit d8d142d

1 file changed

Lines changed: 124 additions & 3 deletions

File tree

crates/settings/src/yaml/context.rs

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,13 +357,25 @@ impl Context {
357357
let var_start = start + var_start;
358358
if let Some(var_end) = result[var_start..].find('}') {
359359
let var_end = var_start + var_end + 1;
360-
let var = &result[var_start + 2..var_end - 1];
361-
let replacement = match self.resolve_path(var) {
360+
let inner = &result[var_start + 2..var_end - 1];
361+
362+
// Split on `||` to extract an optional fallback string literal.
363+
// Syntax: ${path} or ${path || 'fallback'} or ${path || "fallback"}.
364+
// The fallback is used when the path resolves to a missing token field
365+
// (i.e. select-token not yet selected).
366+
let (path, fallback) = parse_path_and_fallback(inner);
367+
368+
let replacement = match self.resolve_path(path) {
362369
Ok(value) => Some(value),
370+
Err(ContextError::PropertyNotFound(property))
371+
if fallback.is_some() && property == "token" =>
372+
{
373+
Some(fallback.unwrap().to_string())
374+
}
363375
Err(ContextError::PropertyNotFound(property))
364376
if allow_select_tokens
365377
&& property == "token"
366-
&& self.select_token_key_for_path(var).is_some() =>
378+
&& self.select_token_key_for_path(path).is_some() =>
367379
{
368380
None
369381
}
@@ -385,6 +397,33 @@ impl Context {
385397
}
386398
}
387399

400+
/// Split a `${...}` body into `(path, optional fallback literal)`.
401+
/// Recognised forms:
402+
/// path
403+
/// path || 'fallback'
404+
/// path || "fallback"
405+
/// Whitespace around `||` and within the literal bounds is trimmed.
406+
/// If the body doesn't match the fallback form, returns `(body_trimmed, None)`.
407+
fn parse_path_and_fallback(body: &str) -> (&str, Option<&str>) {
408+
let Some(or_pos) = body.find("||") else {
409+
return (body.trim(), None);
410+
};
411+
let (left, right) = body.split_at(or_pos);
412+
let path = left.trim();
413+
let right = right[2..].trim();
414+
415+
// Strip matching single or double quotes around the fallback.
416+
let stripped = right
417+
.strip_prefix('\'')
418+
.and_then(|s| s.strip_suffix('\''))
419+
.or_else(|| right.strip_prefix('"').and_then(|s| s.strip_suffix('"')));
420+
421+
match stripped {
422+
Some(literal) => (path, Some(literal)),
423+
None => (body.trim(), None), // malformed — treat whole thing as path
424+
}
425+
}
426+
388427
#[cfg(test)]
389428
mod tests {
390429
use super::*;
@@ -556,6 +595,88 @@ mod tests {
556595
);
557596
}
558597

598+
#[test]
599+
fn test_interpolate_fallback_for_unresolved_token() {
600+
// In strict mode with a select-token not yet selected, a fallback literal
601+
// should be substituted in place of the token path.
602+
let order = setup_select_token_order();
603+
let mut context = Context::new();
604+
context.add_order(order.clone());
605+
context.add_select_tokens(vec!["token1".to_string()]);
606+
607+
let out = context
608+
.interpolate("${order.inputs.0.token.symbol || 'input token'}")
609+
.unwrap();
610+
assert_eq!(out, "input token");
611+
612+
// Double quotes also supported.
613+
let out = context
614+
.interpolate(r#"${order.inputs.0.token.symbol || "input token"}"#)
615+
.unwrap();
616+
assert_eq!(out, "input token");
617+
618+
// Whitespace around || is tolerated.
619+
let out = context
620+
.interpolate("${order.inputs.0.token.symbol||'x'}")
621+
.unwrap();
622+
assert_eq!(out, "x");
623+
624+
// Mixed in a surrounding template string (using only inputs.0 since
625+
// the select-token fixture only has one input).
626+
let out = context
627+
.interpolate("${order.inputs.0.token.symbol || 'buy'} at ${order.inputs.0.token.symbol || 'pair'}")
628+
.unwrap();
629+
assert_eq!(out, "buy at pair");
630+
}
631+
632+
#[test]
633+
fn test_interpolate_fallback_not_used_when_resolved() {
634+
// If the path resolves, the fallback is ignored.
635+
let mut context = Context::new();
636+
let order = setup_test_order_with_vault_id();
637+
context.add_order(order);
638+
639+
let out = context
640+
.interpolate("${order.inputs.0.token.symbol || 'fallback'}")
641+
.unwrap();
642+
// The test token has symbol None, so .symbol still returns empty string or errors.
643+
// Either way, the fallback only kicks in on PropertyNotFound("token"), not on
644+
// a present-but-empty symbol. We just check the fallback isn't blindly applied.
645+
assert_ne!(out, "fallback");
646+
}
647+
648+
#[test]
649+
fn test_interpolate_fallback_not_applied_to_non_token_errors() {
650+
// If the path fails for some reason other than missing token, the fallback
651+
// should NOT be applied — the error should propagate.
652+
let order = setup_select_token_order();
653+
let mut context = Context::new();
654+
context.add_order(order);
655+
context.add_select_tokens(vec!["token1".to_string()]);
656+
657+
let err = context
658+
.interpolate("${order.inputs.0.vault-id || 'default'}")
659+
.unwrap_err();
660+
assert_eq!(err, ContextError::PropertyNotFound("vault-id".to_string()));
661+
}
662+
663+
#[test]
664+
fn test_interpolate_malformed_fallback_is_treated_as_path() {
665+
// ${x || foo} (no quotes) isn't a valid fallback; the body is treated as a
666+
// path. It'll fail to resolve since the path is nonsense.
667+
let mut context = Context::new();
668+
context.add_order(setup_test_order_with_vault_id());
669+
670+
let err = context.interpolate("${bogus || foo}").unwrap_err();
671+
// Just assert it errors rather than silently substituting.
672+
assert!(matches!(
673+
err,
674+
ContextError::InvalidPath(_)
675+
| ContextError::PropertyNotFound(_)
676+
| ContextError::NoOrder
677+
));
678+
}
679+
559680
#[test]
560681
fn test_context_no_order() {
561682
let context = Context::new();

0 commit comments

Comments
 (0)