Skip to main content

soothfast_spec/probe/
assert.rs

1//! Per-probe sanity assertions: `meta.regularMarketPrice > 0`.
2//!
3//! Population says a field exists; an assertion says its value is sane.
4//! The language is one comparison per line — `path op literal` — because
5//! anything richer belongs in a real test, not a probes manifest:
6//!
7//! ```text
8//! meta.regularMarketPrice > 0
9//! candles#len >= 1
10//! candles[].close > 0
11//! meta.currency == "USD"
12//! symbol exists
13//! ```
14//!
15//! `[]` descends into every array element and the assertion must hold for
16//! each; `#len` compares an array or string length. Missing or null
17//! values fail comparisons (an absent price is not `> 0`) but `!=`
18//! against a literal still holds.
19
20use serde_json::Value;
21
22/// One parsed assertion, kept with its source line for reporting.
23#[derive(Debug, Clone)]
24pub struct Assertion {
25    source: String,
26    path: Vec<Step>,
27    length: bool,
28    op: Op,
29    literal: Value,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33enum Step {
34    Key(String),
35    Elements,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq)]
39enum Op {
40    Eq,
41    Ne,
42    Gt,
43    Ge,
44    Lt,
45    Le,
46    Exists,
47}
48
49impl Assertion {
50    pub fn parse(line: &str) -> Result<Assertion, String> {
51        let mut parts = line.split_whitespace();
52        let path_text = parts.next().ok_or("empty assertion")?;
53        let op_text = parts
54            .next()
55            .ok_or_else(|| format!("`{line}`: missing operator"))?;
56        let op = match op_text {
57            "==" => Op::Eq,
58            "!=" => Op::Ne,
59            ">" => Op::Gt,
60            ">=" => Op::Ge,
61            "<" => Op::Lt,
62            "<=" => Op::Le,
63            "exists" => Op::Exists,
64            other => return Err(format!("`{line}`: unknown operator `{other}`")),
65        };
66        let literal = if op == Op::Exists {
67            if parts.next().is_some() {
68                return Err(format!("`{line}`: `exists` takes no operand"));
69            }
70            Value::Null
71        } else {
72            let rest = parts.collect::<Vec<_>>().join(" ");
73            serde_json::from_str(&rest)
74                .map_err(|_| format!("`{line}`: operand `{rest}` is not a JSON literal"))?
75        };
76        let (path_text, length) = match path_text.strip_suffix("#len") {
77            Some(stripped) => (stripped, true),
78            None => (path_text, false),
79        };
80        let mut path = Vec::new();
81        for segment in path_text.split('.').filter(|s| !s.is_empty()) {
82            match segment.strip_suffix("[]") {
83                Some("") => path.push(Step::Elements),
84                Some(key) => {
85                    path.push(Step::Key(key.to_string()));
86                    path.push(Step::Elements);
87                }
88                None => path.push(Step::Key(segment.to_string())),
89            }
90        }
91        Ok(Assertion {
92            source: line.to_string(),
93            path,
94            length,
95            op,
96            literal,
97        })
98    }
99
100    /// The assertion's source line, for failure messages.
101    pub fn source(&self) -> &str {
102        &self.source
103    }
104
105    /// Check the assertion against a response. `Err` carries the reason.
106    pub fn check(&self, value: &Value) -> Result<(), String> {
107        let mut targets = vec![value];
108        for step in &self.path {
109            let mut next = Vec::new();
110            for target in targets {
111                match step {
112                    Step::Key(key) => next.push(target.get(key).unwrap_or(&Value::Null)),
113                    Step::Elements => match target.as_array() {
114                        Some(items) => next.extend(items.iter()),
115                        None => next.push(&Value::Null),
116                    },
117                }
118            }
119            targets = next;
120        }
121        if targets.is_empty() {
122            // An empty array under `[]` leaves nothing to falsify.
123            return Ok(());
124        }
125        for target in targets {
126            self.check_one(target)?;
127        }
128        Ok(())
129    }
130
131    fn check_one(&self, target: &Value) -> Result<(), String> {
132        let length_value;
133        let target = if self.length {
134            let len = match target {
135                Value::Array(items) => items.len(),
136                Value::String(s) => s.len(),
137                other => return Err(format!("{}: {} has no length", self.source, kind(other))),
138            };
139            length_value = Value::from(len);
140            &length_value
141        } else {
142            target
143        };
144        let holds = match self.op {
145            Op::Exists => !target.is_null(),
146            Op::Eq => target == &self.literal,
147            Op::Ne => target != &self.literal,
148            Op::Gt | Op::Ge | Op::Lt | Op::Le => {
149                let (Some(actual), Some(expected)) = (target.as_f64(), self.literal.as_f64())
150                else {
151                    return Err(format!("{}: got {}", self.source, short(target)));
152                };
153                match self.op {
154                    Op::Gt => actual > expected,
155                    Op::Ge => actual >= expected,
156                    Op::Lt => actual < expected,
157                    _ => actual <= expected,
158                }
159            }
160        };
161        if holds {
162            Ok(())
163        } else {
164            Err(format!("{}: got {}", self.source, short(target)))
165        }
166    }
167}
168
169fn kind(value: &Value) -> &'static str {
170    match value {
171        Value::Null => "null",
172        Value::Bool(_) => "boolean",
173        Value::Number(_) => "number",
174        Value::String(_) => "string",
175        Value::Array(_) => "array",
176        Value::Object(_) => "object",
177    }
178}
179
180fn short(value: &Value) -> String {
181    let text = value.to_string();
182    if text.len() > 60 {
183        format!("{}…", &text[..60])
184    } else {
185        text
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use serde_json::json;
193
194    fn assert_holds(line: &str, value: &Value) {
195        Assertion::parse(line).unwrap().check(value).unwrap();
196    }
197
198    fn assert_fails(line: &str, value: &Value) {
199        assert!(Assertion::parse(line).unwrap().check(value).is_err());
200    }
201
202    #[test]
203    fn scalar_comparisons() {
204        let value = json!({"meta": {"price": 12.5, "currency": "USD"}});
205        assert_holds("meta.price > 0", &value);
206        assert_holds("meta.currency == \"USD\"", &value);
207        assert_fails("meta.price < 1", &value);
208        assert_fails("meta.currency == \"EUR\"", &value);
209    }
210
211    #[test]
212    fn missing_and_null_fail_comparisons_but_exist_checks_say_why() {
213        let value = json!({"meta": {"price": null}});
214        assert_fails("meta.price > 0", &value);
215        assert_fails("meta.gone > 0", &value);
216        assert_fails("meta.price exists", &value);
217        assert_holds("meta.price != 3", &value);
218    }
219
220    #[test]
221    fn length_of_arrays_and_strings() {
222        let value = json!({"candles": [1, 2, 3], "symbol": "AAPL"});
223        assert_holds("candles#len >= 1", &value);
224        assert_holds("symbol#len == 4", &value);
225        assert_fails("candles#len > 3", &value);
226        assert_fails("candles.0#len > 0", &value);
227    }
228
229    #[test]
230    fn element_descent_must_hold_for_every_element() {
231        let value = json!({"candles": [{"close": 1.0}, {"close": 0.0}]});
232        assert_fails("candles[].close > 0", &value);
233        assert_holds("candles[].close >= 0", &value);
234        assert_holds("empty[].close > 0", &json!({"empty": []}));
235    }
236
237    #[test]
238    fn parse_rejects_malformed_lines() {
239        assert!(Assertion::parse("price >").is_err());
240        assert!(Assertion::parse("price ~ 3").is_err());
241        assert!(Assertion::parse("price exists 3").is_err());
242        assert!(Assertion::parse("price == not-json").is_err());
243    }
244}