#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextQuerySpec {
pub name: String,
pub bindings: Vec<(String, String)>,
}
fn is_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
}
fn is_param_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
pub fn parse_context_query(spec: &str) -> Result<ContextQuerySpec, String> {
let spec = spec.trim();
let (name, rest) = match spec.find('(') {
None => (spec, None),
Some(i) => {
let inner = spec[i + 1..]
.strip_suffix(')')
.ok_or_else(|| "missing closing ')' after parameter bindings".to_string())?;
(spec[..i].trim_end(), Some(inner))
}
};
if name.is_empty() || !name.chars().all(is_name_char) {
return Err(format!(
"{name:?} is not a saved-query name (allowed: [A-Za-z0-9_.-])"
));
}
let mut bindings = Vec::new();
if let Some(inner) = rest {
if inner.trim().is_empty() {
return Err("empty parameter list — drop the parentheses for a parameterless query"
.to_string());
}
for part in inner.split(',') {
let part = part.trim();
let Some((param, pointer)) = part.split_once('=') else {
return Err(format!(
"binding {part:?} is not of the form $param = /json/pointer"
));
};
let param = param.trim();
let pointer = pointer.trim();
let Some(param) = param.strip_prefix('$') else {
return Err(format!("parameter {param:?} must start with '$'"));
};
if param.is_empty() || !param.chars().all(is_param_char) {
return Err(format!(
"${param} is not a parameter name (allowed: [A-Za-z0-9_])"
));
}
if !pointer.starts_with('/') {
return Err(format!(
"pointer {pointer:?} for ${param} must be a JSON pointer starting with '/'"
));
}
if bindings.iter().any(|(p, _)| p == param) {
return Err(format!("parameter ${param} is bound twice"));
}
bindings.push((param.to_string(), pointer.to_string()));
}
}
Ok(ContextQuerySpec {
name: name.to_string(),
bindings,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_name_parses_with_no_bindings() {
let s = parse_context_query("triage_ctx").unwrap();
assert_eq!(s.name, "triage_ctx");
assert!(s.bindings.is_empty());
}
#[test]
fn bindings_parse_in_order() {
let s =
parse_context_query("triage_ctx($session = /session, $from = /email/from)").unwrap();
assert_eq!(s.name, "triage_ctx");
assert_eq!(
s.bindings,
vec![
("session".to_string(), "/session".to_string()),
("from".to_string(), "/email/from".to_string()),
]
);
}
#[test]
fn whitespace_is_forgiven() {
let s = parse_context_query(" ctx( $a=/x , $b = /y/0 ) ").unwrap();
assert_eq!(s.bindings.len(), 2);
assert_eq!(s.bindings[1], ("b".to_string(), "/y/0".to_string()));
}
#[test]
fn malformed_spellings_refuse() {
for bad in [
"",
"not a name!",
"ctx(",
"ctx()",
"ctx($a)",
"ctx(a = /x)",
"ctx($ = /x)",
"ctx($a = x)",
"ctx($a = /x, $a = /y)",
"ctx($a-b = /x)",
] {
assert!(parse_context_query(bad).is_err(), "{bad:?} must refuse");
}
}
#[test]
fn the_1_5_0_char_class_still_holds_for_names() {
assert!(parse_context_query("a.b-c_9").is_ok());
assert!(parse_context_query("a b").is_err());
}
}