1use std::fmt;
38
39#[derive(Debug, Clone, PartialEq)]
51pub enum QueryNode {
52 Or(Vec<AndGroup>),
53}
54
55#[derive(Debug, Clone, PartialEq)]
59pub struct AndGroup {
60 pub clauses: Vec<Clause>,
61}
62
63#[derive(Debug, Clone, PartialEq)]
65pub enum Clause {
66 Compare {
68 field: String,
69 op: CompareOp,
70 value: QueryValue,
71 },
72 Contains { field: String, value: String },
74 LastDuration(Duration),
76 SinceDatetime(String),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum CompareOp {
86 Eq,
87 NotEq,
88 Gt,
89 Lt,
90}
91
92impl fmt::Display for CompareOp {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.write_str(match self {
95 CompareOp::Eq => "=",
96 CompareOp::NotEq => "!=",
97 CompareOp::Gt => ">",
98 CompareOp::Lt => "<",
99 })
100 }
101}
102
103#[derive(Debug, Clone, PartialEq)]
109pub enum QueryValue {
110 String(String),
111 Integer(i64),
112 Float(f64),
113 Bool(bool),
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub struct Duration {
119 pub amount: u64,
120 pub unit: DurationUnit,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum DurationUnit {
125 Minutes,
126 Hours,
127 Days,
128}
129
130impl DurationUnit {
131 pub fn seconds(self) -> i64 {
134 match self {
135 DurationUnit::Minutes => 60,
136 DurationUnit::Hours => 60 * 60,
137 DurationUnit::Days => 24 * 60 * 60,
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct QueryParseError {
153 pub position: usize,
154 pub message: String,
155}
156
157impl fmt::Display for QueryParseError {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 write!(
160 f,
161 "query parse error at position {}: {}",
162 self.position, self.message
163 )
164 }
165}
166
167impl std::error::Error for QueryParseError {}
168
169#[derive(Debug, Clone, PartialEq)]
174enum Token {
175 Ident(String),
181 QuotedString(String),
183 Number(String),
186 Eq,
187 NotEq,
188 Gt,
189 Lt,
190}
191
192#[derive(Debug, Clone)]
193struct SpannedToken {
194 token: Token,
195 position: usize,
196}
197
198fn is_ident_continuation(b: u8) -> bool {
209 b == b'_' || b == b'.' || b == b'-' || b == b':' || b.is_ascii_alphanumeric()
210}
211
212fn tokenize(input: &str) -> Result<Vec<SpannedToken>, QueryParseError> {
217 let bytes = input.as_bytes();
218 let mut i = 0;
219 let mut out = Vec::new();
220
221 while i < bytes.len() {
222 let c = bytes[i];
223
224 if c.is_ascii_whitespace() {
226 i += 1;
227 continue;
228 }
229
230 if c == b'!' {
233 if i + 1 < bytes.len() && bytes[i + 1] == b'=' {
234 out.push(SpannedToken {
235 token: Token::NotEq,
236 position: i,
237 });
238 i += 2;
239 continue;
240 }
241 return Err(QueryParseError {
242 position: i,
243 message: "unexpected '!' — did you mean '!='?".to_string(),
244 });
245 }
246 if c == b'=' {
247 out.push(SpannedToken {
248 token: Token::Eq,
249 position: i,
250 });
251 i += 1;
252 continue;
253 }
254 if c == b'>' {
255 out.push(SpannedToken {
256 token: Token::Gt,
257 position: i,
258 });
259 i += 1;
260 continue;
261 }
262 if c == b'<' {
263 out.push(SpannedToken {
264 token: Token::Lt,
265 position: i,
266 });
267 i += 1;
268 continue;
269 }
270
271 if c == b'"' {
273 let start = i;
274 i += 1; let content_start = i;
276 while i < bytes.len() && bytes[i] != b'"' {
277 i += 1;
281 }
282 if i >= bytes.len() {
283 return Err(QueryParseError {
284 position: start,
285 message: "unterminated quoted string".to_string(),
286 });
287 }
288 let s = std::str::from_utf8(&bytes[content_start..i])
289 .expect("input is &str, slice is UTF-8")
290 .to_string();
291 i += 1; out.push(SpannedToken {
293 token: Token::QuotedString(s),
294 position: start,
295 });
296 continue;
297 }
298
299 if c.is_ascii_digit() {
314 let start = i;
315 let mut saw_dot = false;
316
317 while i < bytes.len() && (bytes[i].is_ascii_digit() || (bytes[i] == b'.' && !saw_dot)) {
322 if bytes[i] == b'.' {
323 if i + 1 >= bytes.len() || !bytes[i + 1].is_ascii_digit() {
324 break;
325 }
326 saw_dot = true;
327 }
328 i += 1;
329 }
330
331 if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b':' || bytes[i] == b'.') {
347 while i < bytes.len() && is_ident_continuation(bytes[i]) {
348 i += 1;
349 }
350 let s = std::str::from_utf8(&bytes[start..i])
351 .expect("input is &str, slice is UTF-8")
352 .to_string();
353 out.push(SpannedToken {
354 token: Token::Ident(s),
355 position: start,
356 });
357 continue;
358 }
359
360 let s = std::str::from_utf8(&bytes[start..i])
361 .expect("ascii digits are UTF-8")
362 .to_string();
363 out.push(SpannedToken {
364 token: Token::Number(s),
365 position: start,
366 });
367 continue;
368 }
369
370 if c == b'_' || c.is_ascii_alphabetic() {
376 let start = i;
377 while i < bytes.len() && is_ident_continuation(bytes[i]) {
378 i += 1;
379 }
380 let s = std::str::from_utf8(&bytes[start..i])
381 .expect("input is &str, slice is UTF-8")
382 .to_string();
383 out.push(SpannedToken {
384 token: Token::Ident(s),
385 position: start,
386 });
387 continue;
388 }
389
390 return Err(QueryParseError {
391 position: i,
392 message: format!("unexpected character {:?}", c as char),
393 });
394 }
395
396 Ok(out)
397}
398
399pub fn parse(input: &str) -> Result<QueryNode, QueryParseError> {
409 let tokens = tokenize(input)?;
410 if tokens.is_empty() {
411 return Err(QueryParseError {
412 position: 0,
413 message: "empty query".to_string(),
414 });
415 }
416
417 let mut p = Parser {
418 tokens: &tokens,
419 cursor: 0,
420 };
421 let node = p.parse_or_expr()?;
422
423 if let Some(extra) = p.peek() {
427 return Err(QueryParseError {
428 position: extra.position,
429 message: "expected 'AND' or 'OR' between clauses".to_string(),
430 });
431 }
432
433 Ok(node)
434}
435
436struct Parser<'a> {
437 tokens: &'a [SpannedToken],
438 cursor: usize,
439}
440
441impl<'a> Parser<'a> {
442 fn peek(&self) -> Option<&'a SpannedToken> {
443 self.tokens.get(self.cursor)
444 }
445
446 fn advance(&mut self) -> Option<&'a SpannedToken> {
447 let t = self.tokens.get(self.cursor);
448 if t.is_some() {
449 self.cursor += 1;
450 }
451 t
452 }
453
454 fn end_position(&self) -> usize {
456 self.tokens
457 .last()
458 .map(|t| t.position + token_len(&t.token))
459 .unwrap_or(0)
460 }
461
462 fn parse_or_expr(&mut self) -> Result<QueryNode, QueryParseError> {
468 let mut groups = Vec::new();
469 groups.push(self.parse_and_expr()?);
470
471 while let Some(tok) = self.peek() {
472 match &tok.token {
473 Token::Ident(s) if s.eq_ignore_ascii_case("or") => {
474 let or_pos = tok.position;
475 self.advance();
476 match self.peek() {
480 None => {
481 return Err(QueryParseError {
482 position: or_pos,
483 message: "expected a clause after 'OR'".to_string(),
484 });
485 }
486 Some(next) => {
487 if let Token::Ident(s2) = &next.token {
488 if s2.eq_ignore_ascii_case("or") || s2.eq_ignore_ascii_case("and") {
489 return Err(QueryParseError {
490 position: next.position,
491 message: format!(
492 "expected a clause after 'OR', got '{}'",
493 s2.to_uppercase()
494 ),
495 });
496 }
497 }
498 }
499 }
500 groups.push(self.parse_and_expr()?);
501 }
502 _ => break,
503 }
504 }
505
506 Ok(QueryNode::Or(groups))
507 }
508
509 fn parse_and_expr(&mut self) -> Result<AndGroup, QueryParseError> {
511 let mut clauses = Vec::new();
512 clauses.push(self.parse_clause()?);
513
514 while let Some(tok) = self.peek() {
515 match &tok.token {
516 Token::Ident(s) if s.eq_ignore_ascii_case("and") => {
517 let and_pos = tok.position;
518 self.advance();
519 match self.peek() {
521 None => {
522 return Err(QueryParseError {
523 position: and_pos,
524 message: "expected a clause after 'AND'".to_string(),
525 });
526 }
527 Some(next) => {
528 if let Token::Ident(s2) = &next.token {
529 if s2.eq_ignore_ascii_case("and") || s2.eq_ignore_ascii_case("or") {
530 return Err(QueryParseError {
531 position: next.position,
532 message: format!(
533 "expected a clause after 'AND', got '{}'",
534 s2.to_uppercase()
535 ),
536 });
537 }
538 }
539 }
540 }
541 clauses.push(self.parse_clause()?);
542 }
543 Token::Ident(s) if s.eq_ignore_ascii_case("or") => break,
545 _ => break,
549 }
550 }
551
552 Ok(AndGroup { clauses })
553 }
554
555 fn parse_clause(&mut self) -> Result<Clause, QueryParseError> {
556 let tok = self.peek().ok_or_else(|| QueryParseError {
557 position: self.end_position(),
558 message: "expected a clause, got end of input".to_string(),
559 })?;
560
561 if let Token::Ident(s) = &tok.token {
563 if s.eq_ignore_ascii_case("last") {
564 self.advance();
565 return self.parse_last_duration();
566 }
567 if s.eq_ignore_ascii_case("since") {
568 self.advance();
569 return self.parse_since_datetime();
570 }
571 if s.eq_ignore_ascii_case("and") || s.eq_ignore_ascii_case("or") {
574 return Err(QueryParseError {
575 position: tok.position,
576 message: format!("unexpected '{}' at start of clause", s.to_uppercase()),
577 });
578 }
579 }
580
581 self.parse_field_led_clause()
583 }
584
585 fn parse_last_duration(&mut self) -> Result<Clause, QueryParseError> {
586 let num_tok = self.advance().ok_or_else(|| QueryParseError {
587 position: self.end_position(),
588 message: "expected a number after 'last'".to_string(),
589 })?;
590 let num_str = match &num_tok.token {
591 Token::Number(s) => s,
592 _ => {
593 return Err(QueryParseError {
594 position: num_tok.position,
595 message: "expected a number after 'last'".to_string(),
596 });
597 }
598 };
599 if num_str.contains('.') {
600 return Err(QueryParseError {
601 position: num_tok.position,
602 message: "duration amount must be a whole number".to_string(),
603 });
604 }
605 let amount: u64 = num_str.parse().map_err(|_| QueryParseError {
606 position: num_tok.position,
607 message: format!("invalid duration amount {num_str:?}"),
608 })?;
609
610 let unit_tok = self.advance().ok_or_else(|| QueryParseError {
611 position: self.end_position(),
612 message: "expected a duration unit ('m', 'h', or 'd') after the number".to_string(),
613 })?;
614 let unit_str = match &unit_tok.token {
615 Token::Ident(s) => s,
616 _ => {
617 return Err(QueryParseError {
618 position: unit_tok.position,
619 message: "expected a duration unit ('m', 'h', or 'd')".to_string(),
620 });
621 }
622 };
623 let unit = match unit_str.as_str() {
624 "m" => DurationUnit::Minutes,
625 "h" => DurationUnit::Hours,
626 "d" => DurationUnit::Days,
627 other => {
628 return Err(QueryParseError {
629 position: unit_tok.position,
630 message: format!("unknown duration unit {other:?}, expected 'm', 'h', or 'd'"),
631 });
632 }
633 };
634
635 Ok(Clause::LastDuration(Duration { amount, unit }))
636 }
637
638 fn parse_since_datetime(&mut self) -> Result<Clause, QueryParseError> {
639 let tok = self.advance().ok_or_else(|| QueryParseError {
640 position: self.end_position(),
641 message: "expected a datetime after 'since'".to_string(),
642 })?;
643 let dt = match &tok.token {
644 Token::QuotedString(s) => s.clone(),
645 Token::Ident(s) => s.clone(),
646 Token::Number(s) => s.clone(),
647 _ => {
648 return Err(QueryParseError {
649 position: tok.position,
650 message: "expected a datetime after 'since'".to_string(),
651 });
652 }
653 };
654 Ok(Clause::SinceDatetime(dt))
655 }
656
657 fn parse_field_led_clause(&mut self) -> Result<Clause, QueryParseError> {
658 let field_tok = self.advance().expect("caller peeked a token");
659 let field = match &field_tok.token {
660 Token::Ident(s) => s.clone(),
661 _ => {
662 return Err(QueryParseError {
663 position: field_tok.position,
664 message: "expected a field name".to_string(),
665 });
666 }
667 };
668 validate_field_name(&field, field_tok.position)?;
669
670 let op_tok = self.advance().ok_or_else(|| QueryParseError {
671 position: self.end_position(),
672 message: "expected an operator after the field name".to_string(),
673 })?;
674
675 if let Token::Ident(s) = &op_tok.token {
677 if s.eq_ignore_ascii_case("contains") {
678 let val_tok = self.advance().ok_or_else(|| QueryParseError {
679 position: self.end_position(),
680 message: "expected a string after 'contains'".to_string(),
681 })?;
682 let s = match &val_tok.token {
683 Token::QuotedString(s) => s.clone(),
684 Token::Ident(s) => s.clone(),
685 _ => {
686 return Err(QueryParseError {
687 position: val_tok.position,
688 message: "'contains' requires a string value".to_string(),
689 });
690 }
691 };
692 return Ok(Clause::Contains { field, value: s });
693 }
694 }
695
696 let op = match &op_tok.token {
697 Token::Eq => CompareOp::Eq,
698 Token::NotEq => CompareOp::NotEq,
699 Token::Gt => CompareOp::Gt,
700 Token::Lt => CompareOp::Lt,
701 _ => {
702 return Err(QueryParseError {
703 position: op_tok.position,
704 message: "expected one of =, !=, >, <, or 'contains'".to_string(),
705 });
706 }
707 };
708
709 let val_tok = self.advance().ok_or_else(|| QueryParseError {
710 position: self.end_position(),
711 message: "expected a value after the operator".to_string(),
712 })?;
713 let value = token_to_query_value(val_tok)?;
714
715 Ok(Clause::Compare { field, op, value })
716 }
717}
718
719fn validate_field_name(s: &str, position: usize) -> Result<(), QueryParseError> {
726 let mut chars = s.chars();
727 let first = chars.next().ok_or_else(|| QueryParseError {
728 position,
729 message: "empty field name".to_string(),
730 })?;
731 if !(first.is_ascii_alphabetic() || first == '_') {
732 return Err(QueryParseError {
733 position,
734 message: format!("invalid field name {s:?}: must start with a letter or underscore"),
735 });
736 }
737 for c in chars {
738 if !(c.is_ascii_alphanumeric() || c == '_' || c == '.') {
739 return Err(QueryParseError {
740 position,
741 message: format!(
742 "invalid field name {s:?}: only letters, digits, underscores, and dots are allowed"
743 ),
744 });
745 }
746 }
747 Ok(())
748}
749
750fn token_to_query_value(tok: &SpannedToken) -> Result<QueryValue, QueryParseError> {
751 match &tok.token {
752 Token::QuotedString(s) => Ok(QueryValue::String(s.clone())),
753 Token::Number(s) => {
754 if s.contains('.') {
755 let f: f64 = s.parse().map_err(|_| QueryParseError {
756 position: tok.position,
757 message: format!("invalid number {s:?}"),
758 })?;
759 Ok(QueryValue::Float(f))
760 } else {
761 let n: i64 = s.parse().map_err(|_| QueryParseError {
762 position: tok.position,
763 message: format!("invalid integer {s:?}"),
764 })?;
765 Ok(QueryValue::Integer(n))
766 }
767 }
768 Token::Ident(s) => {
769 if s.eq_ignore_ascii_case("true") {
771 Ok(QueryValue::Bool(true))
772 } else if s.eq_ignore_ascii_case("false") {
773 Ok(QueryValue::Bool(false))
774 } else {
775 Ok(QueryValue::String(s.clone()))
776 }
777 }
778 _ => Err(QueryParseError {
779 position: tok.position,
780 message: "expected a value (string, number, or boolean)".to_string(),
781 }),
782 }
783}
784
785fn token_len(t: &Token) -> usize {
786 match t {
787 Token::Ident(s) | Token::Number(s) => s.len(),
788 Token::QuotedString(s) => s.len() + 2, Token::Eq | Token::Gt | Token::Lt => 1,
790 Token::NotEq => 2,
791 }
792}
793
794#[cfg(test)]
799mod tests {
800 use super::*;
801
802 fn one_group(clauses: Vec<Clause>) -> QueryNode {
805 QueryNode::Or(vec![AndGroup { clauses }])
806 }
807
808 fn or_of(groups: Vec<Vec<Clause>>) -> QueryNode {
810 QueryNode::Or(
811 groups
812 .into_iter()
813 .map(|clauses| AndGroup { clauses })
814 .collect(),
815 )
816 }
817
818 fn cmp(field: &str, op: CompareOp, value: QueryValue) -> Clause {
819 Clause::Compare {
820 field: field.to_string(),
821 op,
822 value,
823 }
824 }
825
826 #[test]
831 fn eq_operator() {
832 assert_eq!(
833 parse("level=error").unwrap(),
834 one_group(vec![cmp(
835 "level",
836 CompareOp::Eq,
837 QueryValue::String("error".into())
838 )])
839 );
840 }
841
842 #[test]
843 fn not_eq_operator() {
844 assert_eq!(
845 parse("level!=info").unwrap(),
846 one_group(vec![cmp(
847 "level",
848 CompareOp::NotEq,
849 QueryValue::String("info".into())
850 )])
851 );
852 }
853
854 #[test]
855 fn gt_operator_with_integer() {
856 assert_eq!(
857 parse("req_id > 100").unwrap(),
858 one_group(vec![cmp("req_id", CompareOp::Gt, QueryValue::Integer(100))])
859 );
860 }
861
862 #[test]
863 fn lt_operator_with_float() {
864 assert_eq!(
865 parse("duration < 1.5").unwrap(),
866 one_group(vec![cmp("duration", CompareOp::Lt, QueryValue::Float(1.5))])
867 );
868 }
869
870 #[test]
871 fn contains_operator_with_quoted_string() {
872 assert_eq!(
873 parse(r#"message contains "database timeout""#).unwrap(),
874 one_group(vec![Clause::Contains {
875 field: "message".into(),
876 value: "database timeout".into(),
877 }])
878 );
879 }
880
881 #[test]
882 fn contains_operator_with_bare_word() {
883 assert_eq!(
884 parse("message contains timeout").unwrap(),
885 one_group(vec![Clause::Contains {
886 field: "message".into(),
887 value: "timeout".into(),
888 }])
889 );
890 }
891
892 #[test]
893 fn contains_is_case_insensitive() {
894 assert_eq!(
895 parse("message CONTAINS boom").unwrap(),
896 one_group(vec![Clause::Contains {
897 field: "message".into(),
898 value: "boom".into(),
899 }])
900 );
901 }
902
903 #[test]
904 fn boolean_value() {
905 assert_eq!(
906 parse("ok=true").unwrap(),
907 one_group(vec![cmp("ok", CompareOp::Eq, QueryValue::Bool(true))])
908 );
909 assert_eq!(
910 parse("ok=FALSE").unwrap(),
911 one_group(vec![cmp("ok", CompareOp::Eq, QueryValue::Bool(false))])
912 );
913 }
914
915 #[test]
916 fn quoted_string_value_preserves_spaces() {
917 assert_eq!(
918 parse(r#"service="payments gateway""#).unwrap(),
919 one_group(vec![cmp(
920 "service",
921 CompareOp::Eq,
922 QueryValue::String("payments gateway".into())
923 )])
924 );
925 }
926
927 #[test]
928 fn dotted_field_name_for_nested_json() {
929 assert_eq!(
930 parse("user.id=42").unwrap(),
931 one_group(vec![cmp("user.id", CompareOp::Eq, QueryValue::Integer(42))])
932 );
933 }
934
935 #[test]
940 fn last_minutes() {
941 assert_eq!(
942 parse("last 30m").unwrap(),
943 one_group(vec![Clause::LastDuration(Duration {
944 amount: 30,
945 unit: DurationUnit::Minutes
946 })])
947 );
948 }
949
950 #[test]
951 fn last_hours() {
952 assert_eq!(
953 parse("last 2h").unwrap(),
954 one_group(vec![Clause::LastDuration(Duration {
955 amount: 2,
956 unit: DurationUnit::Hours
957 })])
958 );
959 }
960
961 #[test]
962 fn last_days() {
963 assert_eq!(
964 parse("last 7d").unwrap(),
965 one_group(vec![Clause::LastDuration(Duration {
966 amount: 7,
967 unit: DurationUnit::Days
968 })])
969 );
970 }
971
972 #[test]
973 fn since_datetime_is_opaque_string() {
974 assert_eq!(
975 parse("since 2024-01-01").unwrap(),
976 one_group(vec![Clause::SinceDatetime("2024-01-01".into())])
977 );
978 }
979
980 #[test]
981 fn since_datetime_can_be_quoted() {
982 assert_eq!(
983 parse(r#"since "2024-01-01T10:00:00Z""#).unwrap(),
984 one_group(vec![Clause::SinceDatetime("2024-01-01T10:00:00Z".into())])
985 );
986 }
987
988 #[test]
989 fn since_datetime_bare_with_time_component_parses() {
990 assert_eq!(
991 parse("since 2024-01-01T10:00:00Z").unwrap(),
992 one_group(vec![Clause::SinceDatetime("2024-01-01T10:00:00Z".into())])
993 );
994 }
995
996 #[test]
997 fn since_datetime_bare_followed_by_and_clause() {
998 assert_eq!(
999 parse("since 2024-01-01 AND level=error").unwrap(),
1000 one_group(vec![
1001 Clause::SinceDatetime("2024-01-01".into()),
1002 cmp("level", CompareOp::Eq, QueryValue::String("error".into())),
1003 ])
1004 );
1005 }
1006
1007 #[test]
1012 fn two_clauses_with_and() {
1013 assert_eq!(
1014 parse("level=error AND service=payments").unwrap(),
1015 one_group(vec![
1016 cmp("level", CompareOp::Eq, QueryValue::String("error".into())),
1017 cmp(
1018 "service",
1019 CompareOp::Eq,
1020 QueryValue::String("payments".into())
1021 ),
1022 ])
1023 );
1024 }
1025
1026 #[test]
1027 fn and_is_case_insensitive() {
1028 assert_eq!(
1029 parse("level=error and service=payments").unwrap(),
1030 one_group(vec![
1031 cmp("level", CompareOp::Eq, QueryValue::String("error".into())),
1032 cmp(
1033 "service",
1034 CompareOp::Eq,
1035 QueryValue::String("payments".into())
1036 ),
1037 ])
1038 );
1039 }
1040
1041 #[test]
1042 fn three_clauses_with_time_range() {
1043 assert_eq!(
1044 parse("tag=api AND level=error AND last 30m").unwrap(),
1045 one_group(vec![
1046 cmp("tag", CompareOp::Eq, QueryValue::String("api".into())),
1047 cmp("level", CompareOp::Eq, QueryValue::String("error".into())),
1048 Clause::LastDuration(Duration {
1049 amount: 30,
1050 unit: DurationUnit::Minutes
1051 }),
1052 ])
1053 );
1054 }
1055
1056 #[test]
1061 fn single_or_two_groups() {
1062 assert_eq!(
1064 parse("level=error OR level=warn").unwrap(),
1065 or_of(vec![
1066 vec![cmp(
1067 "level",
1068 CompareOp::Eq,
1069 QueryValue::String("error".into())
1070 )],
1071 vec![cmp(
1072 "level",
1073 CompareOp::Eq,
1074 QueryValue::String("warn".into())
1075 )],
1076 ])
1077 );
1078 }
1079
1080 #[test]
1081 fn or_is_case_insensitive() {
1082 let lowered = parse("level=error or level=warn").unwrap();
1083 let upper = parse("level=error OR level=warn").unwrap();
1084 let mixed = parse("level=error Or level=warn").unwrap();
1085 assert_eq!(lowered, upper);
1086 assert_eq!(lowered, mixed);
1087 }
1088
1089 #[test]
1090 fn three_or_groups() {
1091 assert_eq!(
1092 parse("level=error OR level=warn OR level=fatal").unwrap(),
1093 or_of(vec![
1094 vec![cmp(
1095 "level",
1096 CompareOp::Eq,
1097 QueryValue::String("error".into())
1098 )],
1099 vec![cmp(
1100 "level",
1101 CompareOp::Eq,
1102 QueryValue::String("warn".into())
1103 )],
1104 vec![cmp(
1105 "level",
1106 CompareOp::Eq,
1107 QueryValue::String("fatal".into())
1108 )],
1109 ])
1110 );
1111 }
1112
1113 #[test]
1114 fn or_with_mixed_clause_types() {
1115 assert_eq!(
1118 parse(r#"level=error OR message contains "timeout" OR last 30m"#).unwrap(),
1119 or_of(vec![
1120 vec![cmp(
1121 "level",
1122 CompareOp::Eq,
1123 QueryValue::String("error".into())
1124 )],
1125 vec![Clause::Contains {
1126 field: "message".into(),
1127 value: "timeout".into(),
1128 }],
1129 vec![Clause::LastDuration(Duration {
1130 amount: 30,
1131 unit: DurationUnit::Minutes
1132 })],
1133 ])
1134 );
1135 }
1136
1137 #[test]
1138 fn and_binds_tighter_than_or() {
1139 assert_eq!(
1141 parse("a=1 AND b=2 OR c=3").unwrap(),
1142 or_of(vec![
1143 vec![
1144 cmp("a", CompareOp::Eq, QueryValue::Integer(1)),
1145 cmp("b", CompareOp::Eq, QueryValue::Integer(2)),
1146 ],
1147 vec![cmp("c", CompareOp::Eq, QueryValue::Integer(3))],
1148 ])
1149 );
1150 }
1151
1152 #[test]
1153 fn or_then_and_groups_correctly() {
1154 assert_eq!(
1156 parse("a=1 OR b=2 AND c=3").unwrap(),
1157 or_of(vec![
1158 vec![cmp("a", CompareOp::Eq, QueryValue::Integer(1))],
1159 vec![
1160 cmp("b", CompareOp::Eq, QueryValue::Integer(2)),
1161 cmp("c", CompareOp::Eq, QueryValue::Integer(3)),
1162 ],
1163 ])
1164 );
1165 }
1166
1167 #[test]
1168 fn or_with_and_on_both_sides() {
1169 assert_eq!(
1172 parse("a=1 AND b=2 OR c=3 AND d=4").unwrap(),
1173 or_of(vec![
1174 vec![
1175 cmp("a", CompareOp::Eq, QueryValue::Integer(1)),
1176 cmp("b", CompareOp::Eq, QueryValue::Integer(2)),
1177 ],
1178 vec![
1179 cmp("c", CompareOp::Eq, QueryValue::Integer(3)),
1180 cmp("d", CompareOp::Eq, QueryValue::Integer(4)),
1181 ],
1182 ])
1183 );
1184 }
1185
1186 #[test]
1187 fn or_combines_with_time_ranges() {
1188 assert_eq!(
1190 parse("level=error AND last 1h OR level=fatal").unwrap(),
1191 or_of(vec![
1192 vec![
1193 cmp("level", CompareOp::Eq, QueryValue::String("error".into())),
1194 Clause::LastDuration(Duration {
1195 amount: 1,
1196 unit: DurationUnit::Hours
1197 }),
1198 ],
1199 vec![cmp(
1200 "level",
1201 CompareOp::Eq,
1202 QueryValue::String("fatal".into())
1203 )],
1204 ])
1205 );
1206 }
1207
1208 #[test]
1209 fn no_or_present_still_wraps_in_or_node() {
1210 match parse("level=error").unwrap() {
1213 QueryNode::Or(groups) => {
1214 assert_eq!(groups.len(), 1);
1215 assert_eq!(groups[0].clauses.len(), 1);
1216 }
1217 }
1218 }
1219
1220 #[test]
1225 fn trailing_or_is_an_error() {
1226 let err = parse("level=error OR").unwrap_err();
1227 assert!(err.message.contains("OR"));
1228 assert!(err.message.contains("clause"));
1229 }
1230
1231 #[test]
1232 fn leading_or_is_an_error() {
1233 let err = parse("OR level=error").unwrap_err();
1234 assert!(err.message.contains("OR"));
1235 }
1236
1237 #[test]
1238 fn double_or_is_an_error() {
1239 let err = parse("level=error OR OR level=warn").unwrap_err();
1240 assert!(err.message.contains("clause"));
1241 }
1242
1243 #[test]
1244 fn or_followed_by_and_is_an_error() {
1245 let err = parse("level=error OR AND level=warn").unwrap_err();
1246 assert!(err.message.contains("clause"));
1247 }
1248
1249 #[test]
1250 fn trailing_and_is_an_error() {
1251 let err = parse("level=error AND").unwrap_err();
1252 assert!(err.message.contains("AND"));
1253 assert!(err.message.contains("clause"));
1254 }
1255
1256 #[test]
1257 fn leading_and_is_an_error() {
1258 let err = parse("AND level=error").unwrap_err();
1259 assert!(err.message.contains("AND"));
1260 }
1261
1262 #[test]
1263 fn double_and_is_an_error() {
1264 let err = parse("level=error AND AND service=api").unwrap_err();
1265 assert!(err.message.contains("clause"));
1266 }
1267
1268 #[test]
1269 fn and_followed_by_or_is_an_error() {
1270 let err = parse("level=error AND OR level=warn").unwrap_err();
1271 assert!(err.message.contains("clause"));
1272 }
1273
1274 #[test]
1279 fn empty_query_is_an_error() {
1280 let err = parse("").unwrap_err();
1281 assert_eq!(err.position, 0);
1282 assert!(err.message.contains("empty"));
1283 }
1284
1285 #[test]
1286 fn whitespace_only_query_is_an_error() {
1287 let err = parse(" ").unwrap_err();
1288 assert!(err.message.contains("empty"));
1289 }
1290
1291 #[test]
1292 fn missing_value_after_operator() {
1293 let err = parse("level=").unwrap_err();
1294 assert!(err.message.contains("value"));
1295 }
1296
1297 #[test]
1298 fn missing_operator_after_field() {
1299 let err = parse("level").unwrap_err();
1300 assert!(err.message.contains("operator"));
1301 }
1302
1303 #[test]
1304 fn unknown_duration_unit_names_the_unit() {
1305 let err = parse("last 5y").unwrap_err();
1306 assert!(err.message.contains("unit"));
1307 assert!(err.message.contains("\"y\""));
1308 }
1309
1310 #[test]
1311 fn fractional_duration_rejected() {
1312 let err = parse("last 1.5h").unwrap_err();
1313 assert!(err.message.contains("whole number"));
1314 }
1315
1316 #[test]
1317 fn bang_without_equals_is_actionable() {
1318 let err = parse("level!error").unwrap_err();
1319 assert!(err.message.contains("!="));
1320 }
1321
1322 #[test]
1323 fn unterminated_quoted_string_points_at_opening_quote() {
1324 let input = r#"service="oops"#;
1325 let err = parse(input).unwrap_err();
1326 assert_eq!(err.position, input.find('"').unwrap());
1327 assert!(err.message.contains("unterminated"));
1328 }
1329
1330 #[test]
1331 fn contains_with_number_is_rejected() {
1332 let err = parse("message contains 42").unwrap_err();
1333 assert!(err.message.contains("string"));
1334 }
1335
1336 #[test]
1337 fn invalid_field_name_starting_with_digit() {
1338 let err = parse("3foo=x").unwrap_err();
1339 assert!(err.message.contains("field"));
1340 }
1341
1342 #[test]
1343 fn missing_and_or_or_between_clauses_is_actionable() {
1344 let err = parse("level=error service=payments").unwrap_err();
1345 assert!(err.message.contains("AND") || err.message.contains("OR"));
1347 }
1348
1349 #[test]
1350 fn last_without_number() {
1351 let err = parse("last h").unwrap_err();
1352 assert!(err.message.contains("number"));
1353 }
1354
1355 #[test]
1356 fn last_without_unit() {
1357 let err = parse("last 30").unwrap_err();
1358 assert!(err.message.contains("unit"));
1359 }
1360
1361 #[test]
1366 fn tokens_survive_around_operators_with_no_spaces() {
1367 assert_eq!(
1368 parse("level=error").unwrap(),
1369 parse("level = error").unwrap()
1370 );
1371 assert_eq!(parse("req_id!=5").unwrap(), parse("req_id != 5").unwrap());
1372 }
1373
1374 #[test]
1375 fn hyphenated_bare_word_value_parses() {
1376 assert_eq!(
1377 parse("request_id=x-request-1").unwrap(),
1378 one_group(vec![cmp(
1379 "request_id",
1380 CompareOp::Eq,
1381 QueryValue::String("x-request-1".into())
1382 )])
1383 );
1384 }
1385
1386 #[test]
1387 fn digit_led_value_with_hyphen_is_string_not_number() {
1388 assert_eq!(
1389 parse("version=1.2.3-beta").unwrap(),
1390 one_group(vec![cmp(
1391 "version",
1392 CompareOp::Eq,
1393 QueryValue::String("1.2.3-beta".into())
1394 )])
1395 );
1396 }
1397
1398 #[test]
1399 fn dotted_version_string_is_not_a_number() {
1400 assert_eq!(
1401 parse("version=1.2.3").unwrap(),
1402 one_group(vec![cmp(
1403 "version",
1404 CompareOp::Eq,
1405 QueryValue::String("1.2.3".into())
1406 )])
1407 );
1408 }
1409
1410 #[test]
1411 fn pure_digit_run_is_still_a_number() {
1412 match &parse("req_id=100").unwrap() {
1413 QueryNode::Or(groups) => {
1414 assert_eq!(groups.len(), 1);
1415 match &groups[0].clauses[0] {
1416 Clause::Compare {
1417 value: QueryValue::Integer(n),
1418 ..
1419 } => assert_eq!(*n, 100),
1420 other => panic!("expected Integer value, got {other:?}"),
1421 }
1422 }
1423 }
1424 }
1425}