From f95702cec81fe636ae089ff10d28e8d59af12776 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 09:22:16 +0000 Subject: [PATCH 1/2] feat: template fallback operator ${expr || 'default'} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a template path resolves to a missing token field (select-token not yet selected) and a fallback literal is supplied, substitute the literal instead of leaving the placeholder raw or erroring. Syntax: ${path} — current behaviour ${path || 'fallback'} — new: substitute literal on missing token ${path || "fallback"} — double quotes also accepted Backwards-compatible: existing templates without `||` behave identically. The fallback only kicks in on PropertyNotFound("token") (i.e. unresolved select-token), not on other resolution errors. This unblocks --describe emitting readable field names/descriptions when no token has been selected yet. --- crates/settings/src/yaml/context.rs | 122 +++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/crates/settings/src/yaml/context.rs b/crates/settings/src/yaml/context.rs index 7a88d55735..2502a678ce 100644 --- a/crates/settings/src/yaml/context.rs +++ b/crates/settings/src/yaml/context.rs @@ -357,13 +357,25 @@ impl Context { let var_start = start + var_start; if let Some(var_end) = result[var_start..].find('}') { let var_end = var_start + var_end + 1; - let var = &result[var_start + 2..var_end - 1]; - let replacement = match self.resolve_path(var) { + let inner = &result[var_start + 2..var_end - 1]; + + // Split on `||` to extract an optional fallback string literal. + // Syntax: ${path} or ${path || 'fallback'} or ${path || "fallback"}. + // The fallback is used when the path resolves to a missing token field + // (i.e. select-token not yet selected). + let (path, fallback) = parse_path_and_fallback(inner); + + let replacement = match self.resolve_path(path) { Ok(value) => Some(value), + Err(ContextError::PropertyNotFound(property)) + if fallback.is_some() && property == "token" => + { + Some(fallback.unwrap().to_string()) + } Err(ContextError::PropertyNotFound(property)) if allow_select_tokens && property == "token" - && self.select_token_key_for_path(var).is_some() => + && self.select_token_key_for_path(path).is_some() => { None } @@ -385,6 +397,33 @@ impl Context { } } +/// Split a `${...}` body into `(path, optional fallback literal)`. +/// Recognised forms: +/// path +/// path || 'fallback' +/// path || "fallback" +/// Whitespace around `||` and within the literal bounds is trimmed. +/// If the body doesn't match the fallback form, returns `(body_trimmed, None)`. +fn parse_path_and_fallback(body: &str) -> (&str, Option<&str>) { + let Some(or_pos) = body.find("||") else { + return (body.trim(), None); + }; + let (left, right) = body.split_at(or_pos); + let path = left.trim(); + let right = right[2..].trim(); + + // Strip matching single or double quotes around the fallback. + let stripped = right + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .or_else(|| right.strip_prefix('"').and_then(|s| s.strip_suffix('"'))); + + match stripped { + Some(literal) => (path, Some(literal)), + None => (body.trim(), None), // malformed — treat whole thing as path + } +} + #[cfg(test)] mod tests { use super::*; @@ -556,6 +595,83 @@ mod tests { ); } + #[test] + fn test_interpolate_fallback_for_unresolved_token() { + // In strict mode with a select-token not yet selected, a fallback literal + // should be substituted in place of the token path. + let order = setup_select_token_order(); + let mut context = Context::new(); + context.add_order(order.clone()); + context.add_select_tokens(vec!["token1".to_string()]); + + let out = context + .interpolate("${order.inputs.0.token.symbol || 'input token'}") + .unwrap(); + assert_eq!(out, "input token"); + + // Double quotes also supported. + let out = context + .interpolate(r#"${order.inputs.0.token.symbol || "input token"}"#) + .unwrap(); + assert_eq!(out, "input token"); + + // Whitespace around || is tolerated. + let out = context + .interpolate("${order.inputs.0.token.symbol||'x'}") + .unwrap(); + assert_eq!(out, "x"); + + // Mixed in a surrounding template string (using only inputs.0 since + // the select-token fixture only has one input). + let out = context + .interpolate("${order.inputs.0.token.symbol || 'buy'} at ${order.inputs.0.token.symbol || 'pair'}") + .unwrap(); + assert_eq!(out, "buy at pair"); + } + + #[test] + fn test_interpolate_fallback_not_used_when_resolved() { + // If the path resolves, the fallback is ignored. + let mut context = Context::new(); + let order = setup_test_order_with_vault_id(); + context.add_order(order); + + let out = context + .interpolate("${order.inputs.0.token.symbol || 'fallback'}") + .unwrap(); + // The test token has symbol None, so .symbol still returns empty string or errors. + // Either way, the fallback only kicks in on PropertyNotFound("token"), not on + // a present-but-empty symbol. We just check the fallback isn't blindly applied. + assert_ne!(out, "fallback"); + } + + #[test] + fn test_interpolate_fallback_not_applied_to_non_token_errors() { + // If the path fails for some reason other than missing token, the fallback + // should NOT be applied — the error should propagate. + let order = setup_select_token_order(); + let mut context = Context::new(); + context.add_order(order); + context.add_select_tokens(vec!["token1".to_string()]); + + let err = context + .interpolate("${order.inputs.0.vault-id || 'default'}") + .unwrap_err(); + assert_eq!(err, ContextError::PropertyNotFound("vault-id".to_string())); + } + + #[test] + fn test_interpolate_malformed_fallback_is_treated_as_path() { + // ${x || foo} (no quotes) isn't a valid fallback; the body is treated as a + // path. It'll fail to resolve since the path is nonsense. + let mut context = Context::new(); + context.add_order(setup_test_order_with_vault_id()); + + let err = context.interpolate("${bogus || foo}").unwrap_err(); + // Just assert it errors rather than silently substituting. + assert!(matches!(err, ContextError::InvalidPath(_) | ContextError::PropertyNotFound(_) | ContextError::NoOrder)); + } + #[test] fn test_context_no_order() { let context = Context::new(); From b205a510ef073c9e1eff37fb9f8ea8d8f54a02e9 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Wed, 15 Apr 2026 15:41:39 +0000 Subject: [PATCH 2/2] fmt: wrap long assert in context.rs tests --- crates/settings/src/yaml/context.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/settings/src/yaml/context.rs b/crates/settings/src/yaml/context.rs index 2502a678ce..6783a3c620 100644 --- a/crates/settings/src/yaml/context.rs +++ b/crates/settings/src/yaml/context.rs @@ -669,7 +669,12 @@ mod tests { let err = context.interpolate("${bogus || foo}").unwrap_err(); // Just assert it errors rather than silently substituting. - assert!(matches!(err, ContextError::InvalidPath(_) | ContextError::PropertyNotFound(_) | ContextError::NoOrder)); + assert!(matches!( + err, + ContextError::InvalidPath(_) + | ContextError::PropertyNotFound(_) + | ContextError::NoOrder + )); } #[test]