1use anyhow::{Context, Result, bail};
14use serde_json::Value;
15
16#[derive(Debug, Clone, PartialEq)]
17pub enum NumericConstraint<T> {
18 Eq(T),
19 In(Vec<T>),
20 Gt(T),
21 Gte(T),
22 Lt(T),
23 Lte(T),
24 Between(T, T),
25}
26
27impl NumericConstraint<i64> {
28 pub fn matches(&self, value: i64) -> bool {
29 matches_numeric(self, value)
30 }
31}
32
33impl NumericConstraint<f64> {
34 pub fn matches(&self, value: f64) -> bool {
35 if !value.is_finite() {
36 return false;
37 }
38 matches_numeric(self, value)
41 }
42}
43
44fn matches_numeric<T>(constraint: &NumericConstraint<T>, value: T) -> bool
45where
46 T: PartialOrd + PartialEq + Copy,
47{
48 match constraint {
49 NumericConstraint::Eq(v) => value == *v,
50 NumericConstraint::In(values) => values.contains(&value),
51 NumericConstraint::Gt(v) => value > *v,
52 NumericConstraint::Gte(v) => value >= *v,
53 NumericConstraint::Lt(v) => value < *v,
54 NumericConstraint::Lte(v) => value <= *v,
55 NumericConstraint::Between(min, max) => value >= *min && value <= *max,
56 }
57}
58
59#[derive(Debug, Clone, PartialEq)]
60pub enum EnumConstraint {
61 Eq(String),
62 In(Vec<String>),
63}
64
65impl EnumConstraint {
66 pub fn matches(&self, value: &str) -> bool {
67 let canonical = value.to_lowercase();
68 match self {
69 EnumConstraint::Eq(v) => canonical == *v,
70 EnumConstraint::In(values) => values.iter().any(|v| v == &canonical),
71 }
72 }
73}
74
75pub fn parse_int_constraint(
76 field_name: &str,
77 value: &Value,
78 range: Option<&[i64; 2]>,
79) -> Result<NumericConstraint<i64>> {
80 let object = parse_constraint_object(field_name, value)?;
81 let (operator, operand) = single_operator(field_name, object)?;
82
83 let constraint = match operator {
84 "eq" => NumericConstraint::Eq(parse_int_operand(field_name, operator, operand)?),
85 "in" => NumericConstraint::In(parse_int_array_operand(field_name, operator, operand)?),
86 "gt" => NumericConstraint::Gt(parse_int_operand(field_name, operator, operand)?),
87 "gte" => NumericConstraint::Gte(parse_int_operand(field_name, operator, operand)?),
88 "lt" => NumericConstraint::Lt(parse_int_operand(field_name, operator, operand)?),
89 "lte" => NumericConstraint::Lte(parse_int_operand(field_name, operator, operand)?),
90 "between" => {
91 let (min, max) = parse_int_between_operand(field_name, operator, operand)?;
92 NumericConstraint::Between(min, max)
93 }
94 _ => {
95 bail!(
96 "Field '{}' has invalid operator '{}'. Allowed: eq,in,gt,gte,lt,lte,between",
97 field_name,
98 operator
99 )
100 }
101 };
102
103 validate_int_constraint_against_range(field_name, &constraint, range)?;
104 Ok(constraint)
105}
106
107pub fn parse_float_constraint(
108 field_name: &str,
109 value: &Value,
110 range: Option<&[f64; 2]>,
111) -> Result<NumericConstraint<f64>> {
112 let object = parse_constraint_object(field_name, value)?;
113 let (operator, operand) = single_operator(field_name, object)?;
114
115 let constraint = match operator {
116 "eq" => NumericConstraint::Eq(parse_float_operand(field_name, operator, operand)?),
117 "in" => NumericConstraint::In(parse_float_array_operand(field_name, operator, operand)?),
118 "gt" => NumericConstraint::Gt(parse_float_operand(field_name, operator, operand)?),
119 "gte" => NumericConstraint::Gte(parse_float_operand(field_name, operator, operand)?),
120 "lt" => NumericConstraint::Lt(parse_float_operand(field_name, operator, operand)?),
121 "lte" => NumericConstraint::Lte(parse_float_operand(field_name, operator, operand)?),
122 "between" => {
123 let (min, max) = parse_float_between_operand(field_name, operator, operand)?;
124 NumericConstraint::Between(min, max)
125 }
126 _ => {
127 bail!(
128 "Field '{}' has invalid operator '{}'. Allowed: eq,in,gt,gte,lt,lte,between",
129 field_name,
130 operator
131 )
132 }
133 };
134
135 validate_float_constraint_against_range(field_name, &constraint, range)?;
136 Ok(constraint)
137}
138
139pub fn parse_enum_constraint(
140 field_name: &str,
141 value: &Value,
142 allowed_values: &[String],
143) -> Result<EnumConstraint> {
144 let object = parse_constraint_object(field_name, value)?;
145 let (operator, operand) = single_operator(field_name, object)?;
146 let normalized_allowed: Vec<String> = allowed_values.iter().map(|v| v.to_lowercase()).collect();
147
148 let constraint = match operator {
149 "eq" => {
150 let parsed = parse_enum_operand(field_name, operator, operand)?;
151 validate_enum_value(field_name, &parsed, &normalized_allowed)?;
152 EnumConstraint::Eq(parsed)
153 }
154 "in" => {
155 let parsed = parse_enum_array_operand(field_name, operator, operand)?;
156 for value in &parsed {
157 validate_enum_value(field_name, value, &normalized_allowed)?;
158 }
159 EnumConstraint::In(parsed)
160 }
161 _ => {
162 bail!(
163 "Field '{}' has invalid operator '{}'. Allowed for enum constraints: eq,in",
164 field_name,
165 operator
166 )
167 }
168 };
169
170 Ok(constraint)
171}
172
173fn parse_constraint_object<'a>(
174 field_name: &str,
175 value: &'a Value,
176) -> Result<&'a serde_json::Map<String, Value>> {
177 value.as_object().ok_or_else(|| {
178 anyhow::anyhow!(
179 "Field '{}' constraint must be an object (for example {{\"gte\": 4}})",
180 field_name
181 )
182 })
183}
184
185fn single_operator<'a>(
186 field_name: &str,
187 object: &'a serde_json::Map<String, Value>,
188) -> Result<(&'a str, &'a Value)> {
189 if object.len() != 1 {
190 bail!(
191 "Field '{}' constraint must contain exactly one operator",
192 field_name
193 );
194 }
195 let (operator, operand) = object
196 .iter()
197 .next()
198 .context("constraint object unexpectedly empty")?;
199 Ok((operator.as_str(), operand))
200}
201
202fn parse_int_operand(field_name: &str, operator: &str, value: &Value) -> Result<i64> {
203 value.as_i64().ok_or_else(|| {
204 anyhow::anyhow!(
205 "Field '{}' operator '{}' expects integer value",
206 field_name,
207 operator
208 )
209 })
210}
211
212fn parse_int_array_operand(field_name: &str, operator: &str, value: &Value) -> Result<Vec<i64>> {
213 let values = value.as_array().ok_or_else(|| {
214 anyhow::anyhow!(
215 "Field '{}' operator '{}' expects array of integers",
216 field_name,
217 operator
218 )
219 })?;
220 if values.is_empty() {
221 bail!(
222 "Field '{}' operator '{}' must contain at least one value",
223 field_name,
224 operator
225 );
226 }
227 values
228 .iter()
229 .map(|v| parse_int_operand(field_name, operator, v))
230 .collect()
231}
232
233fn parse_int_between_operand(
234 field_name: &str,
235 operator: &str,
236 value: &Value,
237) -> Result<(i64, i64)> {
238 let values = value.as_array().ok_or_else(|| {
239 anyhow::anyhow!(
240 "Field '{}' operator '{}' expects [min,max]",
241 field_name,
242 operator
243 )
244 })?;
245 if values.len() != 2 {
246 bail!(
247 "Field '{}' operator '{}' expects exactly two values [min,max]",
248 field_name,
249 operator
250 );
251 }
252 let min = parse_int_operand(field_name, operator, &values[0])?;
253 let max = parse_int_operand(field_name, operator, &values[1])?;
254 if min > max {
255 bail!(
256 "Field '{}' operator '{}' expects min <= max",
257 field_name,
258 operator
259 );
260 }
261 Ok((min, max))
262}
263
264fn parse_float_operand(field_name: &str, operator: &str, value: &Value) -> Result<f64> {
265 let parsed = value.as_f64().ok_or_else(|| {
266 anyhow::anyhow!(
267 "Field '{}' operator '{}' expects numeric value",
268 field_name,
269 operator
270 )
271 })?;
272
273 if !parsed.is_finite() {
274 bail!(
275 "Field '{}' operator '{}' expects finite numeric value",
276 field_name,
277 operator
278 );
279 }
280
281 Ok(parsed)
282}
283
284fn parse_float_array_operand(field_name: &str, operator: &str, value: &Value) -> Result<Vec<f64>> {
285 let values = value.as_array().ok_or_else(|| {
286 anyhow::anyhow!(
287 "Field '{}' operator '{}' expects array of numbers",
288 field_name,
289 operator
290 )
291 })?;
292 if values.is_empty() {
293 bail!(
294 "Field '{}' operator '{}' must contain at least one value",
295 field_name,
296 operator
297 );
298 }
299 values
300 .iter()
301 .map(|v| parse_float_operand(field_name, operator, v))
302 .collect()
303}
304
305fn parse_float_between_operand(
306 field_name: &str,
307 operator: &str,
308 value: &Value,
309) -> Result<(f64, f64)> {
310 let values = value.as_array().ok_or_else(|| {
311 anyhow::anyhow!(
312 "Field '{}' operator '{}' expects [min,max]",
313 field_name,
314 operator
315 )
316 })?;
317 if values.len() != 2 {
318 bail!(
319 "Field '{}' operator '{}' expects exactly two values [min,max]",
320 field_name,
321 operator
322 );
323 }
324 let min = parse_float_operand(field_name, operator, &values[0])?;
325 let max = parse_float_operand(field_name, operator, &values[1])?;
326 if min > max {
327 bail!(
328 "Field '{}' operator '{}' expects min <= max",
329 field_name,
330 operator
331 );
332 }
333 Ok((min, max))
334}
335
336fn parse_enum_operand(field_name: &str, operator: &str, value: &Value) -> Result<String> {
337 value.as_str().map(|v| v.to_lowercase()).ok_or_else(|| {
338 anyhow::anyhow!(
339 "Field '{}' operator '{}' expects string value",
340 field_name,
341 operator
342 )
343 })
344}
345
346fn parse_enum_array_operand(
347 field_name: &str,
348 operator: &str,
349 value: &Value,
350) -> Result<Vec<String>> {
351 let values = value.as_array().ok_or_else(|| {
352 anyhow::anyhow!(
353 "Field '{}' operator '{}' expects array of strings",
354 field_name,
355 operator
356 )
357 })?;
358 if values.is_empty() {
359 bail!(
360 "Field '{}' operator '{}' must contain at least one value",
361 field_name,
362 operator
363 );
364 }
365 values
366 .iter()
367 .map(|v| parse_enum_operand(field_name, operator, v))
368 .collect()
369}
370
371fn validate_enum_value(field_name: &str, value: &str, allowed_values: &[String]) -> Result<()> {
372 if !allowed_values.iter().any(|allowed| allowed == value) {
373 bail!(
374 "Field '{}' has invalid enum value '{}'. Allowed: [{}]",
375 field_name,
376 value,
377 allowed_values.join(", ")
378 );
379 }
380 Ok(())
381}
382
383fn validate_int_constraint_against_range(
384 field_name: &str,
385 constraint: &NumericConstraint<i64>,
386 range: Option<&[i64; 2]>,
387) -> Result<()> {
388 let Some([min, max]) = range else {
389 return Ok(());
390 };
391
392 validate_constraint_against_range(constraint, |value| {
393 if value < *min || value > *max {
394 bail!(
395 "Field '{}' constraint value {} is outside allowed range [{}, {}]",
396 field_name,
397 value,
398 min,
399 max
400 );
401 }
402 Ok(())
403 })
404}
405
406fn validate_float_constraint_against_range(
407 field_name: &str,
408 constraint: &NumericConstraint<f64>,
409 range: Option<&[f64; 2]>,
410) -> Result<()> {
411 let Some([min, max]) = range else {
412 return Ok(());
413 };
414
415 validate_constraint_against_range(constraint, |value| {
417 if value < *min || value > *max {
418 bail!(
419 "Field '{}' constraint value {} is outside allowed range [{}, {}]",
420 field_name,
421 value,
422 min,
423 max
424 );
425 }
426 Ok(())
427 })
428}
429
430fn validate_constraint_against_range<T, F>(
431 constraint: &NumericConstraint<T>,
432 mut validate_operand: F,
433) -> Result<()>
434where
435 T: Copy,
436 F: FnMut(T) -> Result<()>,
437{
438 match constraint {
439 NumericConstraint::Eq(v)
440 | NumericConstraint::Gt(v)
441 | NumericConstraint::Gte(v)
442 | NumericConstraint::Lt(v)
443 | NumericConstraint::Lte(v) => validate_operand(*v),
444 NumericConstraint::In(values) => {
445 for value in values {
446 validate_operand(*value)?;
447 }
448 Ok(())
449 }
450 NumericConstraint::Between(from, to) => {
451 validate_operand(*from)?;
452 validate_operand(*to)
453 }
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use serde_json::json;
461
462 #[test]
463 fn int_constraint_parsing_and_matching_works() {
464 let constraint = parse_int_constraint("severity", &json!({"gte": 4}), Some(&[1, 7]))
465 .expect("constraint should parse");
466 assert!(constraint.matches(4));
467 assert!(constraint.matches(7));
468 assert!(!constraint.matches(3));
469 }
470
471 #[test]
472 fn int_constraint_rejects_unknown_operator() {
473 let result = parse_int_constraint("severity", &json!({"ge": 4}), Some(&[1, 7]));
474 assert!(result.is_err());
475 }
476
477 #[test]
478 fn int_constraint_rejects_out_of_range_operand() {
479 let result = parse_int_constraint("severity", &json!({"gte": 9}), Some(&[1, 7]));
480 assert!(result.is_err());
481 }
482
483 #[test]
484 fn int_between_requires_ordered_bounds() {
485 let result = parse_int_constraint("severity", &json!({"between": [6, 2]}), Some(&[1, 7]));
486 assert!(result.is_err());
487 }
488
489 #[test]
490 fn enum_constraint_in_works_case_insensitive() {
491 let constraint = parse_enum_constraint(
492 "alert_level",
493 &json!({"in": ["High", "Critical"]}),
494 &[
495 "low".to_string(),
496 "high".to_string(),
497 "critical".to_string(),
498 ],
499 )
500 .expect("enum constraint should parse");
501 assert!(constraint.matches("high"));
502 assert!(constraint.matches("CRITICAL"));
503 assert!(!constraint.matches("low"));
504 }
505
506 #[test]
507 fn enum_constraint_rejects_invalid_members() {
508 let result = parse_enum_constraint(
509 "alert_level",
510 &json!({"in": ["high", "urgent"]}),
511 &[
512 "low".to_string(),
513 "high".to_string(),
514 "critical".to_string(),
515 ],
516 );
517 assert!(result.is_err());
518 }
519
520 #[test]
521 fn float_constraint_parses_and_matches() {
522 let constraint =
523 parse_float_constraint("temperature", &json!({"between": [10.5, 20.5]}), None)
524 .expect("float constraint should parse");
525 assert!(constraint.matches(15.0));
526 assert!(!constraint.matches(22.0));
527 }
528
529 #[test]
530 fn float_eq_uses_exact_comparison() {
531 let constraint =
532 parse_float_constraint("temperature", &json!({"eq": 0.3}), None).expect("valid eq");
533 assert!(constraint.matches(0.3));
534 assert!(!constraint.matches(0.1 + 0.2));
535 }
536
537 #[test]
538 fn float_in_uses_exact_comparison() {
539 let constraint = parse_float_constraint("temperature", &json!({"in": [0.3, 1.5]}), None)
540 .expect("valid in");
541 assert!(constraint.matches(0.3));
542 assert!(!constraint.matches(0.1 + 0.2));
543 }
544
545 #[test]
546 fn float_between_is_inclusive() {
547 let constraint =
548 parse_float_constraint("temperature", &json!({"between": [10.5, 20.5]}), None)
549 .expect("float between should parse");
550 assert!(constraint.matches(10.5));
551 assert!(constraint.matches(20.5));
552 assert!(!constraint.matches(20.5000001));
553 }
554
555 #[test]
556 fn float_constraint_rejects_non_finite_notification_values() {
557 let constraint =
558 parse_float_constraint("temperature", &json!({"gt": 3.0}), None).expect("valid gt");
559 assert!(!constraint.matches(f64::NAN));
560 assert!(!constraint.matches(f64::INFINITY));
561 assert!(!constraint.matches(f64::NEG_INFINITY));
562 }
563
564 #[test]
565 fn float_constraint_rejects_out_of_range_operand() {
566 let result =
567 parse_float_constraint("temperature", &json!({"gt": 21.0}), Some(&[10.0, 20.0]));
568 assert!(result.is_err());
569 }
570}