1#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ContextQuerySpec {
25 pub name: String,
27 pub bindings: Vec<(String, String)>,
30}
31
32fn is_name_char(c: char) -> bool {
33 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')
34}
35
36fn is_param_char(c: char) -> bool {
37 c.is_ascii_alphanumeric() || c == '_'
38}
39
40pub fn parse_context_query(spec: &str) -> Result<ContextQuerySpec, String> {
52 let spec = spec.trim();
53 let (name, rest) = match spec.find('(') {
54 None => (spec, None),
55 Some(i) => {
56 let inner = spec[i + 1..]
57 .strip_suffix(')')
58 .ok_or_else(|| "missing closing ')' after parameter bindings".to_string())?;
59 (spec[..i].trim_end(), Some(inner))
60 }
61 };
62 if name.is_empty() || !name.chars().all(is_name_char) {
63 return Err(format!(
64 "{name:?} is not a saved-query name (allowed: [A-Za-z0-9_.-])"
65 ));
66 }
67 let mut bindings = Vec::new();
68 if let Some(inner) = rest {
69 if inner.trim().is_empty() {
70 return Err("empty parameter list — drop the parentheses for a parameterless query"
71 .to_string());
72 }
73 for part in inner.split(',') {
74 let part = part.trim();
75 let Some((param, pointer)) = part.split_once('=') else {
76 return Err(format!(
77 "binding {part:?} is not of the form $param = /json/pointer"
78 ));
79 };
80 let param = param.trim();
81 let pointer = pointer.trim();
82 let Some(param) = param.strip_prefix('$') else {
83 return Err(format!("parameter {param:?} must start with '$'"));
84 };
85 if param.is_empty() || !param.chars().all(is_param_char) {
86 return Err(format!(
87 "${param} is not a parameter name (allowed: [A-Za-z0-9_])"
88 ));
89 }
90 if !pointer.starts_with('/') {
91 return Err(format!(
92 "pointer {pointer:?} for ${param} must be a JSON pointer starting with '/'"
93 ));
94 }
95 if bindings.iter().any(|(p, _)| p == param) {
96 return Err(format!("parameter ${param} is bound twice"));
97 }
98 bindings.push((param.to_string(), pointer.to_string()));
99 }
100 }
101 Ok(ContextQuerySpec {
102 name: name.to_string(),
103 bindings,
104 })
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn plain_name_parses_with_no_bindings() {
113 let s = parse_context_query("triage_ctx").unwrap();
114 assert_eq!(s.name, "triage_ctx");
115 assert!(s.bindings.is_empty());
116 }
117
118 #[test]
119 fn bindings_parse_in_order() {
120 let s =
121 parse_context_query("triage_ctx($session = /session, $from = /email/from)").unwrap();
122 assert_eq!(s.name, "triage_ctx");
123 assert_eq!(
124 s.bindings,
125 vec![
126 ("session".to_string(), "/session".to_string()),
127 ("from".to_string(), "/email/from".to_string()),
128 ]
129 );
130 }
131
132 #[test]
133 fn whitespace_is_forgiven() {
134 let s = parse_context_query(" ctx( $a=/x , $b = /y/0 ) ").unwrap();
135 assert_eq!(s.bindings.len(), 2);
136 assert_eq!(s.bindings[1], ("b".to_string(), "/y/0".to_string()));
137 }
138
139 #[test]
140 fn malformed_spellings_refuse() {
141 for bad in [
142 "",
143 "not a name!",
144 "ctx(",
145 "ctx()",
146 "ctx($a)",
147 "ctx(a = /x)",
148 "ctx($ = /x)",
149 "ctx($a = x)",
150 "ctx($a = /x, $a = /y)",
151 "ctx($a-b = /x)",
152 ] {
153 assert!(parse_context_query(bad).is_err(), "{bad:?} must refuse");
154 }
155 }
156
157 #[test]
158 fn the_1_5_0_char_class_still_holds_for_names() {
159 assert!(parse_context_query("a.b-c_9").is_ok());
160 assert!(parse_context_query("a b").is_err());
161 }
162}