Skip to main content

areev_trigger/
context.rs

1//! The `--context-query` declaration spelling (#92).
2//!
3//! A trigger's declared context is a saved query, and since 1.5.1 the
4//! declaration may bind the query's parameters from the FIRING ITEM using
5//! the same JSON pointers `--dedup-key` already understands:
6//!
7//! ```text
8//! triage_ctx                                   # parameterless (1.5.0 form)
9//! triage_ctx($session = /session)              # one binding
10//! triage_ctx($session = /session, $from = /email/from)
11//! ```
12//!
13//! The whole spelling is stored verbatim in the Trigger grain's
14//! `context_query` field, so *what a fired run gets to see* stays on the
15//! declaration — auditable and replicating — rather than in host config.
16//! At fire time the evaluator resolves each pointer against the item's
17//! payload and runs the saved query with those bindings. Resolution is
18//! fail-closed like `--dedup-key`'s unidentifiable path: an unresolvable
19//! pointer, or a pointer landing on a non-scalar, refuses the firing
20//! rather than running the query with a hole in it.
21
22/// A parsed `--context-query` spelling.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ContextQuerySpec {
25    /// The saved-query name (`qry:<name>` in the file's meta registry).
26    pub name: String,
27    /// `($param, /json/pointer)` bindings, in declaration order. Parameter
28    /// names are stored WITHOUT the `$` (matching CAL's `RunQueryStmt`).
29    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
40/// Parse a `--context-query` spelling: `name` or
41/// `name($p = /ptr, $q = /ptr)`.
42///
43/// The plain-name form parses to an empty binding list, byte-identical in
44/// meaning to the 1.5.0 feature. Errors are plain strings — this runs at
45/// declaration time (CLI + `schedule::validate`) and at fire time, and both
46/// surface the message verbatim.
47///
48/// Pointers must start with `/` (RFC 6901) and, because `,` and `)` are
49/// structural in this spelling, cannot contain either character. Keys that
50/// need them can be reached by reshaping the connector payload.
51pub 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}