Skip to main content

logdive_core/
query.rs

1//! Query language: tokenizer, AST, and recursive descent parser.
2//!
3//! Implements the grammar from the project doc's "Notes → Query language
4//! grammar" section, extended in v0.2.0 to support OR per the locked
5//! v0.2.0 scope (handoff doc + 2026-04-19 decisions log entry promising
6//! "OR ships in v2").
7//!
8//! This module owns *only* the parse step: `&str → QueryNode`. Translating
9//! a `QueryNode` into SQL and binding parameters is the executor's job.
10//! Resolving relative time ranges like `last 2h` against wall-clock time
11//! is also the executor's job; the AST just carries the raw spec.
12//!
13//! # Grammar (v0.2.0)
14//!
15//! ```text
16//! query     := or_expr
17//! or_expr   := and_expr (OR and_expr)*
18//! and_expr  := clause (AND clause)*
19//! clause    := field OP value
20//!            | field CONTAINS string
21//!            | TIME_RANGE
22//! field     := [a-zA-Z_][a-zA-Z0-9_.]*
23//! OP        := "=" | "!=" | ">" | "<"
24//! value     := string | number | bool
25//! string    := '"' .* '"' | bare_word
26//! TIME_RANGE := "last" duration | "since" datetime
27//! duration  := number ("m" | "h" | "d")
28//! ```
29//!
30//! AND binds tighter than OR, matching SQL convention. So
31//! `level=error AND service=payments OR level=warn` parses as
32//! `(level=error AND service=payments) OR level=warn`.
33//!
34//! Explicit grouping with `(` `)` is **not** supported in v0.2.0 — users
35//! work with operator precedence only. Parens are a candidate for v0.3.
36
37use std::fmt;
38
39// ---------------------------------------------------------------------------
40// AST
41// ---------------------------------------------------------------------------
42
43/// The top-level query: a disjunction of one or more AND-groups.
44///
45/// A query with no OR (e.g. `level=error AND tag=api`) parses as a single-
46/// element vector containing one `AndGroup`, so the executor has exactly
47/// one code path. This mirrors the v0.1 design choice of always wrapping
48/// a single clause in `And(vec![clause])` — the same idea, lifted up one
49/// level of grammar.
50#[derive(Debug, Clone, PartialEq)]
51pub enum QueryNode {
52    Or(Vec<AndGroup>),
53}
54
55/// A conjunction of one or more clauses, joined by AND.
56///
57/// `clauses` is guaranteed by the parser to be non-empty.
58#[derive(Debug, Clone, PartialEq)]
59pub struct AndGroup {
60    pub clauses: Vec<Clause>,
61}
62
63/// A single clause — the atomic unit a query is built from.
64#[derive(Debug, Clone, PartialEq)]
65pub enum Clause {
66    /// `field OP value` — e.g. `level = error`, `req_id > 100`.
67    Compare {
68        field: String,
69        op: CompareOp,
70        value: QueryValue,
71    },
72    /// `field CONTAINS string` — substring match on a string column.
73    Contains { field: String, value: String },
74    /// `last <N><unit>` — relative time range ending at query time.
75    LastDuration(Duration),
76    /// `since <datetime>` — absolute time range starting at the given moment.
77    /// The string is opaque at the parse layer; the executor uses chrono to
78    /// resolve it (which allows us to accept multiple formats without
79    /// teaching the grammar about any particular one).
80    SinceDatetime(String),
81}
82
83/// Comparison operator for `field OP value` clauses.
84#[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/// A literal value appearing on the right-hand side of a comparison.
104///
105/// The type distinction matters because the executor binds numbers and
106/// booleans with their native SQLite types so numeric comparison
107/// (`req_id > 100`) uses proper ordering rather than lexical.
108#[derive(Debug, Clone, PartialEq)]
109pub enum QueryValue {
110    String(String),
111    Integer(i64),
112    Float(f64),
113    Bool(bool),
114}
115
116/// A relative duration parsed from `last <N><unit>`.
117#[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    /// Total seconds for one unit. The executor multiplies by `amount` to
132    /// compute the cutoff timestamp against `now`.
133    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// ---------------------------------------------------------------------------
143// Errors
144// ---------------------------------------------------------------------------
145
146/// Parse error with a byte offset into the original input.
147///
148/// Byte offsets (rather than line/column) are sufficient because queries
149/// are single-line. The CLI's pretty printer can slice the original input
150/// around `position` to render a caret.
151#[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// ---------------------------------------------------------------------------
170// Tokens
171// ---------------------------------------------------------------------------
172
173#[derive(Debug, Clone, PartialEq)]
174enum Token {
175    /// A bare identifier — could be a field name, a bare-word value, or a
176    /// keyword depending on position. We resolve keywords at parse time
177    /// rather than at tokenization time because "last" used as a field name
178    /// (in the unlikely event a log has a field literally called "last")
179    /// should still work in `CONTAINS` contexts.
180    Ident(String),
181    /// A double-quoted string, with the quotes stripped.
182    QuotedString(String),
183    /// A literal number — stored as text so the parser can decide whether
184    /// it's an integer or float.
185    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
198// ---------------------------------------------------------------------------
199// Tokenizer
200// ---------------------------------------------------------------------------
201
202/// Return true if `b` is allowed *inside* an identifier (but not necessarily
203/// as the first byte). Matches the grammar's field rule plus the extra
204/// characters needed for bare-word values and datetime literals: `-` for
205/// hyphenated values like `x-request-id`, `:` for colon-separated values
206/// like time components, and `.` for both dotted field names and float-like
207/// version strings in values.
208fn is_ident_continuation(b: u8) -> bool {
209    b == b'_' || b == b'.' || b == b'-' || b == b':' || b.is_ascii_alphanumeric()
210}
211
212/// Split the input into a stream of tokens with byte-offset positions.
213///
214/// Whitespace is skipped. Unrecognized bytes produce a `QueryParseError`
215/// pointing at the offending character.
216fn 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        // Whitespace.
225        if c.is_ascii_whitespace() {
226            i += 1;
227            continue;
228        }
229
230        // Operators — order matters: check `!=` before `!` would-be, and
231        // both before single `<`/`>`/`=`.
232        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        // Quoted string.
272        if c == b'"' {
273            let start = i;
274            i += 1; // consume opening quote
275            let content_start = i;
276            while i < bytes.len() && bytes[i] != b'"' {
277                // No escape handling in v1 — the grammar is `'"' .* '"'`
278                // and real log-query users don't embed quotes in values.
279                // If this becomes a pain we add escape handling later.
280                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; // consume closing quote
292            out.push(SpannedToken {
293                token: Token::QuotedString(s),
294                position: start,
295            });
296            continue;
297        }
298
299        // Digit-led token.
300        //
301        // Two possibilities:
302        //  - Pure-digit run (with optional fractional part) → Token::Number.
303        //    Example: `100`, `1.5`.
304        //  - Digit-led run that contains `-` or `:` → Token::Ident. This
305        //    supports bare datetime literals like `2024-01-01T10:00:00Z`
306        //    after `since`. Colon is included for completeness so time-
307        //    of-day literals don't need quoting either.
308        //
309        // The disambiguation happens at the first non-digit, non-dot byte:
310        // if that byte is `-` or `:`, we promote the whole run (and keep
311        // consuming continuation bytes) to an Ident. Otherwise we stop at
312        // the end of the numeric run and emit a Number.
313        if c.is_ascii_digit() {
314            let start = i;
315            let mut saw_dot = false;
316
317            // First phase: consume digits and at most one dot (only when
318            // the dot is followed by a digit, preserving the existing
319            // `1.5` behaviour). We peek at the next byte after each dot
320            // to decide.
321            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            // Second phase: if the next byte indicates this digit-led run
332            // is actually an ident (datetime, multi-dot version string),
333            // keep consuming all ident-continuation bytes and emit Ident.
334            //
335            // Promotion triggers:
336            //   `-` or `:` — datetime literals (`2024-01-01`, `10:30`)
337            //   `.`        — dotted strings beyond one fractional part
338            //                (`1.2.3`); the first phase stops at the
339            //                second dot due to its `!saw_dot` guard,
340            //                leaving `bytes[i]` on that second dot.
341            //
342            // Letters are intentionally NOT a promotion trigger: `30m`
343            // must tokenize as Number("30") + Ident("m") so the parser's
344            // `last <N><unit>` rule works. Users who want digit-led
345            // values with letter suffixes (`3beta`) must quote them.
346            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        // Identifier / bare word: starts with letter or underscore,
371        // continues per `is_ident_continuation`. Hyphen and colon are
372        // allowed inside so bare-word values like `x-request-id` and
373        // colon-separated fragments work; `validate_field_name` later
374        // enforces the stricter field-name subset.
375        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
399// ---------------------------------------------------------------------------
400// Parser
401// ---------------------------------------------------------------------------
402
403/// Parse a query string into a `QueryNode`.
404///
405/// This is the only public entry point. Implements the v0.2.0 grammar
406/// top-down via recursive descent: `or_expr` at the outermost level,
407/// `and_expr` one level in, `clause` at the leaves.
408pub 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    // After a complete or_expr, the only acceptable state is end-of-input.
424    // If a token remains, the user wrote something the grammar doesn't
425    // accept (commonly a missing AND/OR between clauses).
426    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    /// Position to attribute to an error when the tokens are exhausted.
455    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    /// Top-level: one or more AND-groups separated by `OR`.
463    ///
464    /// AND binds tighter than OR. We always wrap the result in
465    /// `QueryNode::Or`, even for a query with no OR present — uniformity
466    /// keeps the executor simple.
467    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                    // Detect trailing-OR or doubled-OR before we recurse.
477                    // Without this the inner parse_and_expr would error
478                    // with a less helpful message.
479                    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    /// One AND-group: one or more clauses joined by `AND`.
510    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                    // Same trailing/double check as for OR.
520                    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                // OR ends this AND-group; the outer or_expr loop picks it up.
544                Token::Ident(s) if s.eq_ignore_ascii_case("or") => break,
545                // Anything else also ends this AND-group; the outer parser
546                // is responsible for deciding whether that's an error
547                // (extra unconsumed token) or success (end of input).
548                _ => 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        // Time-range clauses are keyword-led.
562        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            // A leading bare AND/OR here means the grammar was violated
572            // (e.g. the input started with "OR level=error").
573            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        // Otherwise: field-led clause (compare or contains).
582        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        // CONTAINS is a keyword stored as Ident.
676        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
719/// Enforce the grammar's field regex: `[a-zA-Z_][a-zA-Z0-9_.]*`.
720///
721/// The tokenizer is more permissive (it allows `-` and `:` inside idents
722/// so that bare-word *values* like `x-request-id` and datetime literals
723/// tokenize cleanly). We re-validate here because a field name is a
724/// stricter subset.
725fn 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            // Booleans as bare words.
770            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, // approximate, for error positioning only
789        Token::Eq | Token::Gt | Token::Lt => 1,
790        Token::NotEq => 2,
791    }
792}
793
794// ---------------------------------------------------------------------------
795// Tests
796// ---------------------------------------------------------------------------
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801
802    /// Build a single-AND-group QueryNode (no OR). This is what every v0.1
803    /// query parses into in the new AST shape.
804    fn one_group(clauses: Vec<Clause>) -> QueryNode {
805        QueryNode::Or(vec![AndGroup { clauses }])
806    }
807
808    /// Build a QueryNode with multiple AND-groups (OR present).
809    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    // -----------------------------------------------------------------
827    // Each operator parses correctly (carried forward from v0.1)
828    // -----------------------------------------------------------------
829
830    #[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    // -----------------------------------------------------------------
936    // Time ranges (carried forward from v0.1)
937    // -----------------------------------------------------------------
938
939    #[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    // -----------------------------------------------------------------
1008    // AND chaining (carried forward from v0.1, AST shape updated)
1009    // -----------------------------------------------------------------
1010
1011    #[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    // -----------------------------------------------------------------
1057    // OR — new in v0.2.0
1058    // -----------------------------------------------------------------
1059
1060    #[test]
1061    fn single_or_two_groups() {
1062        // Most common shape: same field, two values.
1063        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        // Each side of OR can be any kind of clause: compare, contains,
1116        // or time range. They mix freely.
1117        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        // `a=1 AND b=2 OR c=3` parses as `(a=1 AND b=2) OR (c=3)`.
1140        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        // `a=1 OR b=2 AND c=3` parses as `(a=1) OR (b=2 AND c=3)`.
1155        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        // `a=1 AND b=2 OR c=3 AND d=4` →
1170        //   `(a=1 AND b=2) OR (c=3 AND d=4)`.
1171        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        // Common in practice: "errors in the last hour OR fatal ever".
1189        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        // The "always wrap" invariant — even a single AND-group is
1211        // structurally `Or(vec![one_group])`.
1212        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    // -----------------------------------------------------------------
1221    // OR error cases
1222    // -----------------------------------------------------------------
1223
1224    #[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    // -----------------------------------------------------------------
1275    // Error cases carried forward from v0.1
1276    // -----------------------------------------------------------------
1277
1278    #[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        // Updated message: parser now suggests AND or OR.
1346        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    // -----------------------------------------------------------------
1362    // Tokenizer edge cases (carried forward from v0.1)
1363    // -----------------------------------------------------------------
1364
1365    #[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}