1use crate::metrics::MetricId;
4use std::fmt;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Op {
9 Lt,
10 Le,
11 Gt,
12 Ge,
13 Eq,
14 Ne,
15}
16
17impl Op {
18 const BY_LENGTH: [Op; 6] = [Op::Le, Op::Ge, Op::Eq, Op::Ne, Op::Lt, Op::Gt];
20
21 pub fn symbol(self) -> &'static str {
23 match self {
24 Op::Lt => "<",
25 Op::Le => "<=",
26 Op::Gt => ">",
27 Op::Ge => ">=",
28 Op::Eq => "==",
29 Op::Ne => "!=",
30 }
31 }
32
33 pub fn from_symbol(s: &str) -> Option<Op> {
35 Op::BY_LENGTH.iter().copied().find(|op| op.symbol() == s)
36 }
37
38 pub fn holds(self, value: f64, limit: f64) -> bool {
41 let equal = (value - limit).abs() <= 1e-9 * value.abs().max(limit.abs()).max(1.0);
42 match self {
43 Op::Lt => value < limit,
44 Op::Le => value <= limit || equal,
45 Op::Gt => value > limit,
46 Op::Ge => value >= limit || equal,
47 Op::Eq => equal,
48 Op::Ne => !equal,
49 }
50 }
51}
52
53impl fmt::Display for Op {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.write_str(self.symbol())
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct Rule {
62 pub metric: MetricId,
63 pub op: Op,
64 pub value: f64,
65}
66
67impl Rule {
68 pub fn parse(text: &str) -> Result<Rule, String> {
71 let text = text.trim();
72 let (at, op) = text
73 .char_indices()
74 .find_map(|(i, _)| {
75 Op::BY_LENGTH
76 .iter()
77 .find(|op| text[i..].starts_with(op.symbol()))
78 .map(|op| (i, *op))
79 })
80 .ok_or_else(|| format!("`{text}` has no operator; use one of < <= > >= == !="))?;
81 let name = text[..at].trim();
82 let value_text = text[at + op.symbol().len()..].trim();
83 let metric = MetricId::from_name(name).ok_or_else(|| {
84 let names: Vec<&str> = MetricId::ALL.iter().map(|m| m.name()).collect();
85 format!(
86 "unknown metric `{name}`; the metrics are {}",
87 names.join(", ")
88 )
89 })?;
90 let value: f64 = value_text
91 .replace('_', "")
92 .parse()
93 .ok()
94 .filter(|v: &f64| v.is_finite())
95 .ok_or_else(|| format!("`{value_text}` is not a number"))?;
96 Ok(Rule { metric, op, value })
97 }
98
99 pub fn eval(&self, value: Option<f64>) -> RuleStatus {
102 match value {
103 None => RuleStatus::Error(format!("{}: no value", self.metric.name())),
104 Some(v) if self.op.holds(v, self.value) => RuleStatus::Pass,
105 Some(v) => RuleStatus::Fail(format!(
106 "{} {}, needs {} {}",
107 self.metric.name(),
108 trim_number(v),
109 self.op,
110 trim_number(self.value)
111 )),
112 }
113 }
114}
115
116impl fmt::Display for Rule {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 write!(
119 f,
120 "{} {} {}",
121 self.metric.name(),
122 self.op,
123 trim_number(self.value)
124 )
125 }
126}
127
128fn trim_number(v: f64) -> String {
130 if v.fract() == 0.0 && v.abs() < 1e15 {
131 format!("{v:.0}")
132 } else {
133 let s = format!("{v:.6}");
134 s.trim_end_matches('0').trim_end_matches('.').to_string()
135 }
136}
137
138#[derive(Debug, Clone, PartialEq)]
140pub enum RuleStatus {
141 Pass,
142 Warn(String),
144 Fail(String),
145 NotApplicable(String),
147 Error(String),
149}
150
151impl RuleStatus {
152 pub fn name(&self) -> &'static str {
154 match self {
155 RuleStatus::Pass => "pass",
156 RuleStatus::Warn(_) => "warn",
157 RuleStatus::Fail(_) => "fail",
158 RuleStatus::NotApplicable(_) => "n/a",
159 RuleStatus::Error(_) => "error",
160 }
161 }
162
163 pub fn message(&self) -> Option<&str> {
165 match self {
166 RuleStatus::Pass => None,
167 RuleStatus::Warn(m)
168 | RuleStatus::Fail(m)
169 | RuleStatus::NotApplicable(m)
170 | RuleStatus::Error(m) => Some(m),
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
177pub enum TargetStatus {
178 Breached,
180 Warned,
182 Unknown,
184 Passed,
186}
187
188impl TargetStatus {
189 pub fn name(self) -> &'static str {
191 match self {
192 TargetStatus::Breached => "breached",
193 TargetStatus::Warned => "warned",
194 TargetStatus::Unknown => "unknown",
195 TargetStatus::Passed => "passed",
196 }
197 }
198}
199
200pub fn target_status(statuses: &[RuleStatus]) -> TargetStatus {
202 let any = |f: fn(&RuleStatus) -> bool| statuses.iter().any(f);
203 if any(|s| matches!(s, RuleStatus::Fail(_))) {
204 TargetStatus::Breached
205 } else if any(|s| matches!(s, RuleStatus::Error(_))) {
206 TargetStatus::Unknown
207 } else if any(|s| matches!(s, RuleStatus::Warn(_))) {
208 TargetStatus::Warned
209 } else {
210 TargetStatus::Passed
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn every_op_below_at_and_above_the_limit() {
220 let table = [
222 (Op::Lt, true, false, false),
223 (Op::Le, true, true, false),
224 (Op::Gt, false, false, true),
225 (Op::Ge, false, true, true),
226 (Op::Eq, false, true, false),
227 (Op::Ne, true, false, true),
228 ];
229 for (op, below, at, above) in table {
230 assert_eq!(op.holds(0.9, 1.0), below, "{op} below");
231 assert_eq!(op.holds(1.0, 1.0), at, "{op} at");
232 assert_eq!(op.holds(1.1, 1.0), above, "{op} above");
233 }
234 assert!(Op::Eq.holds(0.1 + 0.2, 0.3), "float noise is equal");
235 }
236
237 #[test]
238 fn rules_parse_with_and_without_spaces_and_print_back() {
239 for (text, metric, op, value) in [
240 ("holders >= 1000", MetricId::Holders, Op::Ge, 1000.0),
241 ("spread_pct<0.5", MetricId::SpreadPct, Op::Lt, 0.5),
242 (
243 "liquidity_usd > 1_000_000",
244 MetricId::LiquidityUsd,
245 Op::Gt,
246 1e6,
247 ),
248 ("risk_score != 10", MetricId::RiskScore, Op::Ne, 10.0),
249 ("mid_price == 1", MetricId::Mid, Op::Eq, 1.0),
250 (
251 "native_balance <= -1",
252 MetricId::NativeBalance,
253 Op::Le,
254 -1.0,
255 ),
256 ] {
257 let r = Rule::parse(text).unwrap();
258 assert_eq!((r.metric, r.op, r.value), (metric, op, value), "{text}");
259 assert_eq!(Rule::parse(&r.to_string()).unwrap(), r);
260 }
261 assert_eq!(
262 Rule::parse("holders >= 1000").unwrap().to_string(),
263 "holders >= 1000"
264 );
265 }
266
267 #[test]
268 fn bad_rules_say_why() {
269 assert!(
270 Rule::parse("holders 1000")
271 .unwrap_err()
272 .contains("no operator")
273 );
274 let e = Rule::parse("holdrs > 1").unwrap_err();
275 assert!(
276 e.contains("unknown metric `holdrs`") && e.contains("holders"),
277 "{e}"
278 );
279 assert!(
280 Rule::parse("holders > lots")
281 .unwrap_err()
282 .contains("not a number")
283 );
284 assert!(
285 Rule::parse("holders > inf")
286 .unwrap_err()
287 .contains("not a number")
288 );
289 }
290
291 #[test]
292 fn eval_gives_pass_fail_and_error_for_no_value() {
293 let r = Rule::parse("spread_pct <= 3").unwrap();
294 assert_eq!(r.eval(Some(1.0)), RuleStatus::Pass);
295 assert_eq!(
296 r.eval(Some(3.4)),
297 RuleStatus::Fail("spread_pct 3.4, needs <= 3".into())
298 );
299 assert_eq!(
300 r.eval(None),
301 RuleStatus::Error("spread_pct: no value".into())
302 );
303 }
304
305 #[test]
306 fn target_status_rules() {
307 use RuleStatus::*;
308 let s = |v: &[RuleStatus]| target_status(v);
309 assert_eq!(
310 s(&[Pass, Fail("f".into()), Error("e".into())]),
311 TargetStatus::Breached
312 );
313 assert_eq!(
314 s(&[Pass, Error("e".into()), Warn("w".into())]),
315 TargetStatus::Unknown
316 );
317 assert_eq!(s(&[Pass, Warn("w".into())]), TargetStatus::Warned);
318 assert_eq!(s(&[NotApplicable("n".into())]), TargetStatus::Passed);
319 assert_eq!(s(&[]), TargetStatus::Passed);
320 let mut order = vec![
321 TargetStatus::Passed,
322 TargetStatus::Unknown,
323 TargetStatus::Breached,
324 TargetStatus::Warned,
325 ];
326 order.sort();
327 assert_eq!(
328 order,
329 [
330 TargetStatus::Breached,
331 TargetStatus::Warned,
332 TargetStatus::Unknown,
333 TargetStatus::Passed
334 ]
335 );
336 }
337}