Skip to main content

dol/
parser.rs

1//! DOL query parser — tokenizer and recursive-descent parser.
2//!
3//! Converts a DOL query string (e.g. `"observe containers | where cpu > 80%"`)
4//! into an [`crate::ast::Query`] value. Provides rich [`ParseError`] messages with
5//! source pointers, colour-coded output, and keyword suggestions via
6//! Levenshtein distance.
7//!
8//! # Example
9//!
10//! ```ignore
11//! let parsed = parser::parse("observe containers | select name, state")?;
12//! println!("{:?}", parsed.query);
13//! ```
14
15use serde::Serialize;
16
17use crate::ast::{
18    AlertAction, AlertRule, AnalysisTarget, AnalysisVerb, BinOp, CollectionTarget, ComposeTarget,
19    ConfigTarget, Duration, DurationUnit, EventsQuery, Expression, InspectQuery, LogsQuery,
20    Operator, PipelineNode, Query, SetValue, SingularTarget, SingularTargetKind, SortDirection,
21    TimeSelector, Value,
22};
23use crate::{ANSI_BOLD, ANSI_FG_CYAN, ANSI_FG_GREEN, ANSI_FG_RED, ANSI_FG_YELLOW, ANSI_RESET};
24
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct ParsedQuery {
27    pub source: String,
28    pub query: Query,
29}
30
31#[derive(Debug, Clone, Eq, PartialEq)]
32pub struct ParseError {
33    pub column: usize,
34    pub message: String,
35    /// The original query source (trimmed), used to show surrounding context.
36    pub source: Option<String>,
37    /// An optional suggestion to help the user fix the error.
38    pub suggestion: Option<String>,
39}
40
41impl std::error::Error for ParseError {}
42
43impl std::fmt::Display for ParseError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        // For Display trait, use plain text (no colors).
46        // Use format_color for ANSI-colored output.
47        write!(f, "parse error at column {}: {}", self.column, self.message)?;
48
49        self.fmt_source_pointer(f)?;
50        self.fmt_suggestion_plain(f)?;
51
52        Ok(())
53    }
54}
55
56impl ParseError {
57    pub fn new(column: usize, message: impl Into<String>) -> Self {
58        Self {
59            column,
60            message: message.into(),
61            source: None,
62            suggestion: None,
63        }
64    }
65
66    #[must_use]
67    pub fn with_source(mut self, source: String) -> Self {
68        self.source = Some(source);
69        self
70    }
71
72    /// Set a suggestion for how to fix the error.
73    #[must_use]
74    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
75        self.suggestion = Some(suggestion.into());
76        self
77    }
78
79    /// Return a fully ANSI-coloured error string suitable for terminal display.
80    #[must_use]
81    pub fn format_color(&self) -> String {
82        let mut out = String::new();
83
84        // Error header: bold red "error" prefix
85        use std::fmt::Write;
86        write!(
87            out,
88            "{ansi_bold}{ansi_red}error{reset} at column {col}: {msg}",
89            ansi_bold = ANSI_BOLD,
90            ansi_red = ANSI_FG_RED,
91            reset = ANSI_RESET,
92            col = self.column,
93            msg = self.message,
94        )
95        .expect("write! to String is infallible");
96
97        // Source line with pointer
98        if let Some(ref source) = self.source {
99            let mut remaining = self.column;
100            let display_line = source
101                .lines()
102                .find(|line| {
103                    let line_len = line.chars().count();
104                    if remaining <= line_len || line_len == 0 && remaining == 1 {
105                        true
106                    } else {
107                        remaining = remaining.saturating_sub(line_len + 1);
108                        false
109                    }
110                })
111                .unwrap_or(source);
112
113            let col = remaining.min(display_line.chars().count());
114            let pointer = format!("{}^", " ".repeat(col.saturating_sub(1)));
115
116            write!(
117                out,
118                "\n  {bold}{cyan}-->{reset} {line}",
119                bold = ANSI_BOLD,
120                cyan = ANSI_FG_CYAN,
121                reset = ANSI_RESET,
122                line = display_line,
123            )
124            .expect("write! to String is infallible");
125            write!(
126                out,
127                "\n     {green}{ptr}{reset}",
128                green = ANSI_FG_GREEN,
129                ptr = pointer,
130                reset = ANSI_RESET,
131            )
132            .expect("write! to String is infallible");
133        }
134
135        // Suggestion in yellow
136        if let Some(ref suggestion) = self.suggestion {
137            write!(
138                out,
139                "\n  {yellow}help: {suggestion}{reset}",
140                yellow = ANSI_FG_YELLOW,
141                reset = ANSI_RESET,
142            )
143            .expect("write! to String is infallible");
144        }
145
146        out
147    }
148
149    /// Append the source pointer lines to a formatter (plain text).
150    fn fmt_source_pointer(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        if let Some(ref source) = self.source {
152            let mut remaining = self.column;
153            let display_line = source
154                .lines()
155                .find(|line| {
156                    let line_len = line.chars().count();
157                    if remaining <= line_len || line_len == 0 && remaining == 1 {
158                        true
159                    } else {
160                        remaining = remaining.saturating_sub(line_len + 1);
161                        false
162                    }
163                })
164                .unwrap_or(source);
165
166            let col = remaining.min(display_line.chars().count());
167            write!(f, "\n  --> {display_line}")?;
168            write!(f, "\n     {}^", " ".repeat(col.saturating_sub(1)))?;
169        }
170        Ok(())
171    }
172
173    /// Append suggestion as plain text.
174    fn fmt_suggestion_plain(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        if let Some(ref suggestion) = self.suggestion {
176            write!(f, "\n  help: {suggestion}")?;
177        }
178        Ok(())
179    }
180}
181
182pub fn parse(source: &str) -> Result<ParsedQuery, ParseError> {
183    let trimmed = source.trim();
184
185    let result = (|| {
186        if trimmed.is_empty() {
187            return Err(
188                ParseError::new(1, "empty DOL query").with_suggestion("try `observe containers`")
189            );
190        }
191
192        let tokens = tokenize(trimmed)?;
193        let mut parser = Parser::new(tokens, trimmed.chars().count() + 1);
194        let query = parser.parse_query()?;
195        parser.expect_eof()?;
196
197        Ok(ParsedQuery {
198            source: trimmed.to_owned(),
199            query,
200        })
201    })();
202
203    // Attach the trimmed source to all parse errors for context display.
204    result.map_err(|e| e.with_source(trimmed.to_owned()))
205}
206
207#[derive(Debug, Clone, PartialEq)]
208struct Token {
209    kind: TokenKind,
210    column: usize,
211}
212
213#[derive(Debug, Clone, PartialEq)]
214enum TokenKind {
215    Ident(String),
216    String(String),
217    Integer(i64),
218    Float(f64),
219    Percentage(f64),
220    Duration(Duration),
221    Eq,
222    NotEq,
223    Gt,
224    Lt,
225    Gte,
226    Lte,
227    Pipe,
228    Comma,
229    LParen,
230    RParen,
231    Plus,
232    Minus,
233    Star,
234    Slash,
235    Percent,
236}
237
238struct Parser {
239    tokens: Vec<Token>,
240    cursor: usize,
241    eof_column: usize,
242}
243
244impl Parser {
245    const fn new(tokens: Vec<Token>, eof_column: usize) -> Self {
246        Self {
247            tokens,
248            cursor: 0,
249            eof_column,
250        }
251    }
252
253    // ── Query dispatch ──
254
255    fn parse_query(&mut self) -> Result<Query, ParseError> {
256        match self.peek_ident() {
257            Some("observe") => self.parse_observe(),
258            Some("events") => self.parse_events(),
259            Some("inspect") => self.parse_inspect(),
260            Some("analyze") => self.parse_analyze(),
261            Some("alert") => self.parse_alert_rule().map(Query::Alert),
262            Some("logs") => self.parse_logs(),
263            Some("compose") => self.parse_compose(),
264            Some("ping") => {
265                self.advance();
266                Ok(Query::Ping)
267            }
268            Some("fields") => self.parse_fields(),
269            Some(other) => Err(self
270                .error_here(format!(
271                    "expected query family, found `{other}`"
272                ))
273                .with_suggestion("try one of: `observe`, `events`, `inspect`, `analyze`, `alert`, `compose`, `logs`, `ping`".to_string())),
274            None => Err(self.error_here("expected query family")),
275        }
276    }
277
278    fn parse_observe(&mut self) -> Result<Query, ParseError> {
279        self.expect_ident("observe")?;
280        // Support "observe compose <project>" syntax
281        if self.consume_ident("compose") {
282            let project = self.expect_identifier_like("compose project name")?;
283            let target = self.parse_compose_target();
284            let pipeline = self.parse_pipeline()?;
285            return Ok(Query::Compose(crate::ast::ComposeQuery {
286                project,
287                target,
288                pipeline,
289                service: None,
290                port_number: None,
291                tail: None,
292                config_target: None,
293            }));
294        }
295        let target = self.parse_collection_target()?;
296        let time = self.parse_optional_time_selector()?;
297        let filter = self.parse_optional_inline_where()?;
298        let join = if self.consume_ident("join") {
299            let right = self.parse_collection_target()?;
300            self.expect_ident("on")?;
301            let left_key = self.parse_arith_expression()?;
302            self.expect(&TokenKind::Eq, "`=`")?;
303            let right_key = self.parse_arith_expression()?;
304            Some(crate::ast::JoinClause {
305                right,
306                left_key,
307                right_key,
308            })
309        } else {
310            None
311        };
312        let pipeline = self.parse_pipeline()?;
313        Ok(Query::Observe(crate::ast::ObserveQuery {
314            target,
315            time,
316            filter,
317            join,
318            pipeline,
319        }))
320    }
321
322    fn parse_events(&mut self) -> Result<Query, ParseError> {
323        self.expect_ident("events")?;
324        let target = self.parse_collection_target()?;
325        let time = self.parse_optional_time_selector()?;
326        let filter = self.parse_optional_inline_where()?;
327        let pipeline = self.parse_pipeline()?;
328        Ok(Query::Events(EventsQuery {
329            target,
330            time,
331            filter,
332            pipeline,
333        }))
334    }
335
336    fn parse_inspect(&mut self) -> Result<Query, ParseError> {
337        self.expect_ident("inspect")?;
338        let target = self.parse_singular_target()?;
339        let at = if self.consume_ident("at") {
340            Some(self.expect_timestamp_string()?)
341        } else {
342            None
343        };
344        Ok(Query::Inspect(InspectQuery { target, at }))
345    }
346
347    fn parse_analyze(&mut self) -> Result<Query, ParseError> {
348        self.expect_ident("analyze")?;
349        let target = self.parse_analysis_target()?;
350        let verb = self.parse_analysis_verb()?;
351        let subject = if self.is_at_time_or_pipe_or_eof() {
352            None
353        } else {
354            Some(self.expect_identifier_like("analysis subject")?)
355        };
356        let time = self.parse_optional_time_selector()?;
357        let pipeline = self.parse_pipeline()?;
358        Ok(Query::Analyze(crate::ast::AnalyzeQuery {
359            target,
360            verb,
361            subject,
362            time,
363            pipeline,
364        }))
365    }
366
367    fn parse_alert_rule(&mut self) -> Result<AlertRule, ParseError> {
368        self.expect_ident("alert")?;
369        self.expect_ident("when")?;
370        let condition = self.parse_expression()?;
371        let duration = if self.consume_ident("for") {
372            Some(self.expect_duration()?)
373        } else {
374            None
375        };
376        self.expect_ident("then")?;
377        let action = self.parse_alert_action()?;
378        Ok(AlertRule {
379            condition,
380            duration,
381            action,
382        })
383    }
384
385    fn parse_compose(&mut self) -> Result<Query, ParseError> {
386        self.expect_ident("compose")?;
387
388        if self.consume_ident("ls") {
389            let pipeline = self.parse_pipeline()?;
390            return Ok(Query::Compose(crate::ast::ComposeQuery {
391                project: String::new(),
392                target: ComposeTarget::Projects,
393                pipeline,
394                service: None,
395                port_number: None,
396                tail: None,
397                config_target: None,
398            }));
399        }
400
401        let project = self.expect_identifier_like("compose project name")?;
402        let target = self.parse_compose_target();
403
404        let (service, port_number, tail, config_target) = match target {
405            ComposeTarget::Logs => {
406                let svc = self.expect_identifier_like("service name")?;
407                let t = if self.consume_ident("tail") {
408                    Some(self.expect_u64("tail")?)
409                } else {
410                    None
411                };
412                (Some(svc), None, t, None)
413            }
414            ComposeTarget::Port => {
415                let svc = self.expect_identifier_like("service name")?;
416                let port = Some(self.expect_u64("port number")?);
417                (Some(svc), port, None, None)
418            }
419            ComposeTarget::Config => {
420                let ct = if self.consume_ident("services") {
421                    Some(ConfigTarget::Services)
422                } else if self.consume_ident("networks") {
423                    Some(ConfigTarget::Networks)
424                } else if self.consume_ident("volumes") {
425                    Some(ConfigTarget::Volumes)
426                } else {
427                    Some(ConfigTarget::All)
428                };
429                (None, None, None, ct)
430            }
431            _ => (None, None, None, None),
432        };
433
434        let pipeline = self.parse_pipeline()?;
435        Ok(Query::Compose(crate::ast::ComposeQuery {
436            project,
437            target,
438            pipeline,
439            service,
440            port_number,
441            tail,
442            config_target,
443        }))
444    }
445
446    fn parse_compose_target(&mut self) -> ComposeTarget {
447        if self.consume_ident("services") {
448            ComposeTarget::Services
449        } else if self.consume_ident("networks") {
450            ComposeTarget::Networks
451        } else if self.consume_ident("volumes") {
452            ComposeTarget::Volumes
453        } else if self.consume_ident("health") {
454            ComposeTarget::Health
455        } else if self.consume_ident("images") {
456            ComposeTarget::Images
457        } else if self.consume_ident("stats") {
458            ComposeTarget::Stats
459        } else if self.consume_ident("ps") {
460            ComposeTarget::Ps
461        } else if self.consume_ident("events") {
462            ComposeTarget::Events
463        } else if self.consume_ident("port") {
464            ComposeTarget::Port
465        } else if self.consume_ident("config") {
466            ComposeTarget::Config
467        } else if self.consume_ident("logs") {
468            ComposeTarget::Logs
469        } else {
470            self.consume_ident("containers");
471            ComposeTarget::Containers
472        }
473    }
474
475    fn parse_logs(&mut self) -> Result<Query, ParseError> {
476        self.expect_ident("logs")?;
477        self.expect_ident("container")?;
478        let container = self.expect_identifier_like("container name or ID")?;
479        let tail = if self.consume_ident("tail") {
480            Some(self.expect_u64("tail")?)
481        } else {
482            None
483        };
484        let filter = self.parse_optional_inline_where()?;
485        let pipeline = self.parse_pipeline()?;
486        Ok(Query::Logs(LogsQuery {
487            container,
488            tail,
489            filter,
490            pipeline,
491        }))
492    }
493
494    fn parse_fields(&mut self) -> Result<Query, ParseError> {
495        self.expect_ident("fields")?;
496        let target = self.parse_collection_target()?;
497        Ok(Query::Fields(target))
498    }
499
500    // ── Target parsing ──
501
502    fn parse_collection_target(&mut self) -> Result<CollectionTarget, ParseError> {
503        match self.peek_ident() {
504            Some("containers") => {
505                self.advance();
506                Ok(CollectionTarget::Containers)
507            }
508            Some("images") => {
509                self.advance();
510                Ok(CollectionTarget::Images)
511            }
512            Some("networks") => {
513                self.advance();
514                Ok(CollectionTarget::Networks)
515            }
516            Some("volumes") => {
517                self.advance();
518                Ok(CollectionTarget::Volumes)
519            }
520            Some(other) => {
521                let suggestion = suggest_similar_target(other);
522                Err(self
523                    .error_here(format!("expected collection target, found `{other}`"))
524                    .with_suggestion(suggestion))
525            }
526            None => Err(self.error_here(
527                "expected collection target: containers, images, networks, or volumes",
528            )),
529        }
530    }
531
532    fn parse_singular_target(&mut self) -> Result<SingularTarget, ParseError> {
533        let kind = match self.peek_ident() {
534            Some("container") => {
535                self.advance();
536                SingularTargetKind::Container
537            }
538            Some("image") => {
539                self.advance();
540                SingularTargetKind::Image
541            }
542            Some("network") => {
543                self.advance();
544                SingularTargetKind::Network
545            }
546            Some("volume") => {
547                self.advance();
548                SingularTargetKind::Volume
549            }
550            Some(other) => {
551                let suggestion = suggest_similar_target(other);
552                return Err(self
553                    .error_here(format!("expected singular target, found `{other}`"))
554                    .with_suggestion(suggestion));
555            }
556            None => {
557                return Err(self
558                    .error_here("expected singular target: container, image, network, or volume"));
559            }
560        };
561        let value = self.expect_identifier_like("target name or ID")?;
562        Ok(SingularTarget { kind, value })
563    }
564
565    fn parse_analysis_target(&mut self) -> Result<AnalysisTarget, ParseError> {
566        if let Ok(target) = self.parse_collection_target() {
567            return Ok(AnalysisTarget::Collection(target));
568        }
569        self.parse_singular_target().map(AnalysisTarget::Singular)
570    }
571
572    fn parse_analysis_verb(&mut self) -> Result<AnalysisVerb, ParseError> {
573        match self.peek_ident() {
574            Some("find") => {
575                self.advance();
576                Ok(AnalysisVerb::Find)
577            }
578            Some("correlate") => {
579                self.advance();
580                Ok(AnalysisVerb::Correlate)
581            }
582            Some("explain") => {
583                self.advance();
584                Ok(AnalysisVerb::Explain)
585            }
586            Some(other) => {
587                let extra = suggest_keyword(other, ANALYSIS_VERBS);
588                let base = "try one of: `find`, `correlate`, `explain`";
589                let suggestion = match extra {
590                    Some(s) => format!("{s} {base}"),
591                    None => base.to_owned(),
592                };
593                Err(self
594                    .error_here(format!("expected analysis verb, found `{other}`"))
595                    .with_suggestion(suggestion))
596            }
597            None => Err(self.error_here("expected analysis verb: find, correlate, or explain")),
598        }
599    }
600
601    fn parse_alert_action(&mut self) -> Result<AlertAction, ParseError> {
602        match self.peek_ident() {
603            Some("print") => {
604                self.advance();
605                let msg = self.expect_string("alert message")?;
606                Ok(AlertAction::Print(msg))
607            }
608            Some("webhook") => {
609                self.advance();
610                let url = self.expect_string("webhook URL")?;
611                Ok(AlertAction::Webhook(url))
612            }
613            Some("restart") => {
614                self.advance();
615                let target = self.parse_singular_target()?;
616                Ok(AlertAction::Restart(target))
617            }
618            Some("slack") => {
619                self.advance();
620                let url = self.expect_string("Slack webhook URL")?;
621                Ok(AlertAction::Slack(url))
622            }
623            Some("discord") => {
624                self.advance();
625                let url = self.expect_string("Discord webhook URL")?;
626                Ok(AlertAction::Discord(url))
627            }
628            Some("email") => {
629                self.advance();
630                let to = self.expect_string("email recipient")?;
631                let subject = if self.consume_ident("subject") {
632                    self.expect_string("email subject")?
633                } else {
634                    "DockQL Alert Notification".to_owned()
635                };
636                Ok(AlertAction::Email { to, subject })
637            }
638            Some(other) => {
639                let extra = suggest_keyword(other, ALERT_ACTIONS);
640                let base = "try one of: `print`, `webhook`, `restart`, `slack`, `discord`, `email`";
641                let suggestion = match extra {
642                    Some(s) => format!("{s} {base}"),
643                    None => base.to_owned(),
644                };
645                Err(self
646                    .error_here(format!("expected alert action, found `{other}`"))
647                    .with_suggestion(suggestion))
648            }
649            None => Err(self.error_here(
650                "expected alert action: print, webhook, restart, slack, discord, or email",
651            )),
652        }
653    }
654
655    // ── Time parsing ──
656
657    fn parse_optional_time_selector(&mut self) -> Result<Option<TimeSelector>, ParseError> {
658        if self.consume_ident("last") {
659            let duration = self.expect_duration()?;
660            Ok(Some(TimeSelector::Last(duration)))
661        } else if self.consume_ident("from") {
662            let from = self.expect_timestamp_string()?;
663            self.expect_ident("to")?;
664            let to = self.expect_timestamp_string()?;
665            Ok(Some(TimeSelector::Range { from, to }))
666        } else {
667            Ok(None)
668        }
669    }
670
671    fn parse_optional_inline_where(&mut self) -> Result<Option<Expression>, ParseError> {
672        if self.consume_ident("where") {
673            Ok(Some(self.parse_expression()?))
674        } else {
675            Ok(None)
676        }
677    }
678
679    // ── Pipeline parsing ──
680
681    fn parse_pipeline(&mut self) -> Result<Vec<PipelineNode>, ParseError> {
682        let mut nodes = Vec::new();
683        while self.consume(&TokenKind::Pipe) {
684            nodes.push(self.parse_pipeline_node()?);
685        }
686        Ok(nodes)
687    }
688
689    fn parse_pipeline_node(&mut self) -> Result<PipelineNode, ParseError> {
690        if self.consume_ident("where") {
691            return Ok(PipelineNode::Where(self.parse_expression()?));
692        }
693        if self.consume_ident("select") {
694            return Ok(PipelineNode::Select(self.parse_field_list()?));
695        }
696        if self.consume_ident("group") {
697            self.expect_ident("by")?;
698            let fields = self.parse_field_list()?;
699            let aggregates = if self.consume_ident("with") {
700                self.parse_aggregates()?
701            } else {
702                Vec::new()
703            };
704            return Ok(PipelineNode::GroupBy { fields, aggregates });
705        }
706        if self.consume_ident("having") {
707            return Ok(PipelineNode::Having(self.parse_expression()?));
708        }
709        if self.consume_ident("sort") {
710            self.consume_ident("by");
711            let mut fields = Vec::new();
712            loop {
713                let field = self.expect_identifier_like("sort field")?;
714                let direction = if self.consume_ident("desc") {
715                    SortDirection::Desc
716                } else {
717                    self.consume_ident("asc");
718                    SortDirection::Asc
719                };
720                fields.push((field, direction));
721                if !self.consume(&TokenKind::Comma) {
722                    break;
723                }
724            }
725            return Ok(PipelineNode::SortBy { fields });
726        }
727        if self.consume_ident("limit") {
728            return Ok(PipelineNode::Limit(self.expect_u64("limit")?));
729        }
730        if self.consume_ident("offset") {
731            return Ok(PipelineNode::Offset(self.expect_u64("offset")?));
732        }
733        if self.consume_ident("distinct") {
734            return Ok(PipelineNode::Distinct);
735        }
736        if self.consume_ident("alert") {
737            return Ok(PipelineNode::Alert(self.expect_string("alert message")?));
738        }
739        if self.consume_ident("if") {
740            return self.parse_if_pipeline();
741        }
742        if self.consume_ident("set") {
743            return self.parse_set_pipeline();
744        }
745        if self.consume_ident("debug") {
746            return Ok(PipelineNode::Debug);
747        }
748        if self.consume_ident("assert") {
749            return Ok(PipelineNode::Assert(self.parse_expression()?));
750        }
751        if self.consume_ident("fill") {
752            return self.parse_fill_pipeline();
753        }
754        if self.consume_ident("row_number") {
755            return self.parse_row_number_pipeline();
756        }
757        if self.consume_ident("rank") {
758            return self.parse_window_func_with_field("rank");
759        }
760        if self.consume_ident("lag") {
761            return self.parse_window_func_with_field("lag");
762        }
763        if self.consume_ident("lead") {
764            return self.parse_window_func_with_field("lead");
765        }
766        if self.consume_ident("let") {
767            return self.parse_let_pipeline();
768        }
769        Err({
770            let extra = self.peek().and_then(|t| match &t.kind {
771                TokenKind::Ident(name) => suggest_keyword(name, PIPELINE_KEYWORDS),
772                _ => None,
773            });
774            let base = "use `| where`, `| select`, `| sort`, `| group by`, `| limit`, `| set`, `| if`, `| fill`, `| let`, `| row_number`, `| rank`, `| lag`, `| lead`, `| debug`, or `| assert`";
775            let suggestion = match extra {
776                Some(s) => format!("{s} {base}"),
777                None => base.to_owned(),
778            };
779            self.error_here("expected pipeline node")
780                .with_suggestion(suggestion)
781        })
782    }
783
784    /// Parse an aggregate argument value (identifier, integer, float, or string).
785    fn parse_aggregate_arg(&mut self) -> Result<String, ParseError> {
786        let token = self
787            .advance()
788            .ok_or_else(|| self.error_here("expected aggregate argument"))?;
789        match token.kind {
790            TokenKind::Ident(s) | TokenKind::String(s) => Ok(s),
791            TokenKind::Integer(n) => Ok(n.to_string()),
792            TokenKind::Float(f) => Ok(f.to_string()),
793            _ => Err(ParseError::new(
794                token.column,
795                "expected aggregate argument (identifier, number, or string)",
796            )),
797        }
798    }
799
800    fn parse_aggregates(&mut self) -> Result<Vec<crate::ast::AggregateExpr>, ParseError> {
801        let mut aggs = Vec::new();
802        loop {
803            let func = self.expect_identifier_like("aggregate function")?;
804            self.expect(&TokenKind::LParen, "`(`")?;
805            let field = self.expect_identifier_like("aggregate field")?;
806            // Parse optional extra arguments (e.g., percentile(field, 95))
807            let mut args = Vec::new();
808            if self.consume(&TokenKind::Comma) {
809                let arg = self.parse_aggregate_arg()?;
810                args.push(arg);
811                while self.consume(&TokenKind::Comma) {
812                    let arg = self.parse_aggregate_arg()?;
813                    args.push(arg);
814                }
815            }
816            self.expect(&TokenKind::RParen, "`)`")?;
817            let alias = if self.consume_ident("as") {
818                self.expect_identifier_like("alias")?
819            } else {
820                field.clone()
821            };
822            aggs.push(crate::ast::AggregateExpr {
823                function: func,
824                field,
825                alias,
826                args,
827            });
828            if !self.consume(&TokenKind::Comma) {
829                break;
830            }
831        }
832        Ok(aggs)
833    }
834
835    fn parse_if_pipeline(&mut self) -> Result<PipelineNode, ParseError> {
836        let condition = self.parse_expression()?;
837        self.expect_ident("then")?;
838        let then_branch = self.parse_inline_pipeline_nodes()?;
839        let else_branch = if self.consume_ident("else") {
840            if self.consume_ident("if") {
841                Some(vec![self.parse_if_pipeline()?])
842            } else {
843                Some(self.parse_inline_pipeline_nodes()?)
844            }
845        } else {
846            None
847        };
848        Ok(PipelineNode::If {
849            condition,
850            then_branch,
851            else_branch,
852        })
853    }
854
855    fn parse_inline_pipeline_nodes(&mut self) -> Result<Vec<PipelineNode>, ParseError> {
856        let mut nodes = vec![self.parse_pipeline_node()?];
857        while self.consume(&TokenKind::Pipe) {
858            nodes.push(self.parse_pipeline_node()?);
859        }
860        Ok(nodes)
861    }
862
863    fn parse_set_pipeline(&mut self) -> Result<PipelineNode, ParseError> {
864        let field = self.expect_identifier_like("field name")?;
865        self.expect(&TokenKind::Eq, "`=`")?;
866        let value = if self.consume_ident("case") {
867            self.parse_set_case()?
868        } else if self.consume_ident("if") {
869            self.parse_set_if_else()?
870        } else {
871            SetValue::Expr(self.parse_expression()?)
872        };
873        Ok(PipelineNode::Set { field, value })
874    }
875
876    fn parse_set_case(&mut self) -> Result<SetValue, ParseError> {
877        let mut when_clauses = Vec::new();
878        while self.consume_ident("when") {
879            let condition = self.parse_expression()?;
880            self.expect_ident("then")?;
881            let result = self.parse_expression()?;
882            when_clauses.push((condition, result));
883        }
884        let else_value = if self.consume_ident("else") {
885            Some(self.parse_expression()?)
886        } else {
887            None
888        };
889        self.expect_ident("end")?;
890        Ok(SetValue::Case {
891            when_clauses,
892            else_value,
893        })
894    }
895
896    fn parse_set_if_else(&mut self) -> Result<SetValue, ParseError> {
897        let condition = self.parse_expression()?;
898        self.expect_ident("then")?;
899        let then_value = self.parse_expression()?;
900        let else_value = if self.consume_ident("else") {
901            Some(self.parse_expression()?)
902        } else {
903            None
904        };
905        Ok(SetValue::IfElse {
906            condition,
907            then_value,
908            else_value,
909        })
910    }
911
912    fn parse_field_list(&mut self) -> Result<Vec<String>, ParseError> {
913        let mut fields = vec![self.expect_identifier_like("field")?];
914        while self.consume(&TokenKind::Comma) {
915            fields.push(self.expect_identifier_like("field")?);
916        }
917        Ok(fields)
918    }
919
920    // ── Expression parsing with arithmetic precedence ──
921
922    fn parse_expression(&mut self) -> Result<Expression, ParseError> {
923        self.parse_or_expression()
924    }
925
926    fn parse_or_expression(&mut self) -> Result<Expression, ParseError> {
927        let mut expr = self.parse_and_expression()?;
928        while self.consume_ident("or") {
929            let rhs = self.parse_and_expression()?;
930            expr = Expression::Or(Box::new(expr), Box::new(rhs));
931        }
932        Ok(expr)
933    }
934
935    fn parse_and_expression(&mut self) -> Result<Expression, ParseError> {
936        let mut expr = self.parse_not_expression()?;
937        while self.consume_ident("and") {
938            let rhs = self.parse_not_expression()?;
939            expr = Expression::And(Box::new(expr), Box::new(rhs));
940        }
941        Ok(expr)
942    }
943
944    fn parse_not_expression(&mut self) -> Result<Expression, ParseError> {
945        if self.consume_ident("not") {
946            return Ok(Expression::Not(Box::new(self.parse_not_expression()?)));
947        }
948        self.parse_comparison_expression()
949    }
950
951    fn parse_comparison_expression(&mut self) -> Result<Expression, ParseError> {
952        let left = self.parse_arith_expression()?;
953
954        // Check for `in`
955        if self.consume_ident("in") {
956            self.expect(&TokenKind::LParen, "`(`")?;
957            let values = self.parse_value_list()?;
958            self.expect(&TokenKind::RParen, "`)`")?;
959            return Ok(Expression::In {
960                expr: Box::new(left),
961                values,
962            });
963        }
964
965        // Check for `is null` / `is not null`
966        if self.consume_ident("is") {
967            if self.consume_ident("not") {
968                self.expect_ident("null")?;
969                return Ok(Expression::IsNotNull(Box::new(left)));
970            }
971            self.expect_ident("null")?;
972            return Ok(Expression::IsNull(Box::new(left)));
973        }
974
975        // Check for `between`
976        if self.consume_ident("between") {
977            let low = self.parse_arith_expression()?;
978            self.expect_ident("and")?;
979            let high = self.parse_arith_expression()?;
980            return Ok(Expression::Between {
981                expr: Box::new(left),
982                low: Box::new(low),
983                high: Box::new(high),
984            });
985        }
986
987        // Check for comparison operator
988        if let Some(op) = self.parse_optional_operator() {
989            let right = self.parse_rhs_expression()?;
990            return Ok(Expression::Comparison {
991                left: Box::new(left),
992                operator: op,
993                right: Box::new(right),
994            });
995        }
996
997        Ok(left)
998    }
999
1000    /// Parse the right-hand side of a comparison, treating bare identifiers
1001    /// as literal values (not field references) for backward compatibility.
1002    fn parse_rhs_expression(&mut self) -> Result<Expression, ParseError> {
1003        // Try to parse as a simple value first
1004        if let Ok(value) = self.try_parse_rhs_value() {
1005            return Ok(Expression::Literal(value));
1006        }
1007        // Fall back to full expression (function calls, arithmetic, parens)
1008        self.parse_arith_expression()
1009    }
1010
1011    /// Like `try_parse_value` but accepts ANY identifier (not just true/false)
1012    /// so that `state = running` treats `running` as a literal.
1013    fn try_parse_rhs_value(&mut self) -> Result<Value, ParseError> {
1014        let token = self
1015            .peek()
1016            .ok_or_else(|| self.error_here("expected value"))?
1017            .clone();
1018        match &token.kind {
1019            TokenKind::String(_)
1020            | TokenKind::Ident(_)
1021            | TokenKind::Integer(_)
1022            | TokenKind::Float(_)
1023            | TokenKind::Percentage(_) => {}
1024            _ => return Err(ParseError::new(token.column, "not a value")),
1025        }
1026        self.advance();
1027        match token.kind {
1028            TokenKind::String(value) => Ok(Value::String(value)),
1029            TokenKind::Ident(value) if value == "true" => Ok(Value::Boolean(true)),
1030            TokenKind::Ident(value) if value == "false" => Ok(Value::Boolean(false)),
1031            TokenKind::Ident(value) if value.starts_with('$') => {
1032                Ok(Value::Identifier(value[1..].to_owned()))
1033            }
1034            TokenKind::Ident(value) => Ok(Value::Identifier(value)),
1035            TokenKind::Integer(value) => Ok(Value::Integer(value)),
1036            TokenKind::Float(value) => Ok(Value::Float(value)),
1037            TokenKind::Percentage(value) => Ok(Value::Percentage(value)),
1038            _ => unreachable!(),
1039        }
1040    }
1041
1042    fn parse_arith_expression(&mut self) -> Result<Expression, ParseError> {
1043        self.parse_term(0)
1044    }
1045
1046    // Precedence climbing: + - (lowest), * / % (middle), unary - (highest)
1047    fn parse_term(&mut self, min_prec: u8) -> Result<Expression, ParseError> {
1048        let mut left = self.parse_unary_arith()?;
1049
1050        loop {
1051            let op = match self.peek().map(|t| &t.kind) {
1052                Some(TokenKind::Plus) if 1 >= min_prec => {
1053                    self.advance();
1054                    BinOp::Add
1055                }
1056                Some(TokenKind::Minus) if 1 >= min_prec => {
1057                    self.advance();
1058                    BinOp::Sub
1059                }
1060                Some(TokenKind::Star) if 2 >= min_prec => {
1061                    self.advance();
1062                    BinOp::Mul
1063                }
1064                Some(TokenKind::Slash) if 2 >= min_prec => {
1065                    self.advance();
1066                    BinOp::Div
1067                }
1068                Some(TokenKind::Percent) if 2 >= min_prec => {
1069                    self.advance();
1070                    BinOp::Mod
1071                }
1072                _ => break,
1073            };
1074            let next_prec = match op {
1075                BinOp::Add | BinOp::Sub => 1,
1076                BinOp::Mul | BinOp::Div | BinOp::Mod => 2,
1077            };
1078            let right = self.parse_term(next_prec)?;
1079            left = Expression::Arithmetic {
1080                left: Box::new(left),
1081                op,
1082                right: Box::new(right),
1083            };
1084        }
1085
1086        Ok(left)
1087    }
1088
1089    fn parse_unary_arith(&mut self) -> Result<Expression, ParseError> {
1090        if self.consume(&TokenKind::Minus) {
1091            let expr = self.parse_unary_arith()?;
1092            return Ok(Expression::Arithmetic {
1093                left: Box::new(Expression::Literal(Value::Integer(0))),
1094                op: BinOp::Sub,
1095                right: Box::new(expr),
1096            });
1097        }
1098        self.parse_primary_expression()
1099    }
1100
1101    fn parse_primary_expression(&mut self) -> Result<Expression, ParseError> {
1102        // Parenthesized expression
1103        if self.consume(&TokenKind::LParen) {
1104            let expr = self.parse_expression()?;
1105            self.expect(&TokenKind::RParen, "`)`")?;
1106            return Ok(expr);
1107        }
1108
1109        // Function call: ident "(" ...
1110        if let Some(Token {
1111            kind: TokenKind::Ident(name),
1112            ..
1113        }) = self.peek()
1114            && let Some(peek2) = self.tokens.get(self.cursor + 1)
1115            && matches!(peek2.kind, TokenKind::LParen)
1116        {
1117            let name = name.clone();
1118            self.advance(); // consume ident
1119            self.advance(); // consume LParen
1120            let mut args = Vec::new();
1121            if !self.check(&TokenKind::RParen) {
1122                args.push(self.parse_expression()?);
1123                while self.consume(&TokenKind::Comma) {
1124                    args.push(self.parse_expression()?);
1125                }
1126            }
1127            self.expect(&TokenKind::RParen, "`)`")?;
1128            return Ok(Expression::FnCall { name, args });
1129        }
1130
1131        // Try literal value first
1132        if let Ok(value) = self.try_parse_value() {
1133            return Ok(Expression::Literal(value));
1134        }
1135
1136        // Field reference (with optional $ prefix for variable-style access)
1137        let field = self.expect_identifier_like("field")?;
1138        #[allow(clippy::option_if_let_else)]
1139        if let Some(name) = field.strip_prefix('$') {
1140            Ok(Expression::Field(name.to_owned()))
1141        } else {
1142            Ok(Expression::Field(field))
1143        }
1144    }
1145
1146    fn parse_value_list(&mut self) -> Result<Vec<Value>, ParseError> {
1147        let mut values = vec![self.parse_value()?];
1148        while self.consume(&TokenKind::Comma) {
1149            values.push(self.parse_value()?);
1150        }
1151        Ok(values)
1152    }
1153
1154    fn parse_fill_pipeline(&mut self) -> Result<PipelineNode, ParseError> {
1155        let field = self.expect_identifier_like("field name")?;
1156        self.expect_ident("with")?;
1157        let default = if self.consume_ident("case") {
1158            self.parse_set_case()?
1159        } else if self.consume_ident("if") {
1160            self.parse_set_if_else()?
1161        } else {
1162            crate::ast::SetValue::Expr(self.parse_expression()?)
1163        };
1164        let condition = if self.consume_ident("where") {
1165            Some(self.parse_expression()?)
1166        } else {
1167            None
1168        };
1169        Ok(PipelineNode::Fill {
1170            field,
1171            default,
1172            condition,
1173        })
1174    }
1175
1176    fn parse_row_number_pipeline(&mut self) -> Result<PipelineNode, ParseError> {
1177        self.consume_ident("as");
1178        let alias = self.expect_identifier_like("alias for row number")?;
1179        Ok(PipelineNode::RowNumber { alias })
1180    }
1181
1182    fn parse_window_func_with_field(
1183        &mut self,
1184        func_name: &str,
1185    ) -> Result<PipelineNode, ParseError> {
1186        self.expect(&TokenKind::LParen, "`(`")?;
1187        let field = self.expect_identifier_like("field name")?;
1188        let mut offset = 1u64;
1189        if self.consume(&TokenKind::Comma) {
1190            offset = self.expect_u64("offset")?;
1191        }
1192        self.expect(&TokenKind::RParen, "`)`")?;
1193        self.consume_ident("as");
1194        let alias = self.expect_identifier_like("alias")?;
1195        match func_name {
1196            "rank" => Ok(PipelineNode::Rank { field, alias }),
1197            "lag" => Ok(PipelineNode::Lag {
1198                field,
1199                alias,
1200                offset,
1201            }),
1202            "lead" => Ok(PipelineNode::Lead {
1203                field,
1204                alias,
1205                offset,
1206            }),
1207            _ => unreachable!(),
1208        }
1209    }
1210
1211    fn parse_let_pipeline(&mut self) -> Result<PipelineNode, ParseError> {
1212        let name = self.expect_identifier_like("variable name")?;
1213        #[allow(clippy::option_if_let_else)]
1214        let name = if let Some(stripped) = name.strip_prefix('$') {
1215            stripped.to_owned()
1216        } else {
1217            name
1218        };
1219        self.expect(&TokenKind::Eq, "`=`")?;
1220        let value = self.parse_expression()?;
1221        Ok(PipelineNode::Let { name, value })
1222    }
1223
1224    fn parse_optional_operator(&mut self) -> Option<Operator> {
1225        let op = match self.peek().map(|t| &t.kind) {
1226            Some(TokenKind::Eq) => Operator::Eq,
1227            Some(TokenKind::NotEq) => Operator::NotEq,
1228            Some(TokenKind::Gt) => Operator::Gt,
1229            Some(TokenKind::Lt) => Operator::Lt,
1230            Some(TokenKind::Gte) => Operator::Gte,
1231            Some(TokenKind::Lte) => Operator::Lte,
1232            Some(TokenKind::Ident(v)) if v == "contains" => Operator::Contains,
1233            Some(TokenKind::Ident(v)) if v == "matches" => Operator::Matches,
1234            Some(TokenKind::Ident(v)) if v == "starts_with" => Operator::StartsWith,
1235            Some(TokenKind::Ident(v)) if v == "ends_with" => Operator::EndsWith,
1236            _ => return None,
1237        };
1238        self.advance();
1239        Some(op)
1240    }
1241
1242    fn try_parse_value(&mut self) -> Result<Value, ParseError> {
1243        let token = self
1244            .peek()
1245            .ok_or_else(|| self.error_here("expected value"))?
1246            .clone();
1247        match &token.kind {
1248            TokenKind::Ident(v) if v == "true" || v == "false" => {}
1249            TokenKind::String(_)
1250            | TokenKind::Integer(_)
1251            | TokenKind::Float(_)
1252            | TokenKind::Percentage(_) => {}
1253            _ => return Err(ParseError::new(token.column, "not a value")),
1254        }
1255        self.advance();
1256        match token.kind {
1257            TokenKind::String(value) => Ok(Value::String(value)),
1258            TokenKind::Ident(value) if value == "true" => Ok(Value::Boolean(true)),
1259            TokenKind::Ident(value) if value == "false" => Ok(Value::Boolean(false)),
1260            TokenKind::Ident(value) if value.starts_with('$') => {
1261                Ok(Value::Identifier(value[1..].to_owned()))
1262            }
1263            TokenKind::Ident(value) => Ok(Value::Identifier(value)),
1264            TokenKind::Integer(value) => Ok(Value::Integer(value)),
1265            TokenKind::Float(value) => Ok(Value::Float(value)),
1266            TokenKind::Percentage(value) => Ok(Value::Percentage(value)),
1267            _ => unreachable!(),
1268        }
1269    }
1270
1271    fn parse_value(&mut self) -> Result<Value, ParseError> {
1272        let token = self
1273            .advance()
1274            .ok_or_else(|| self.error_here("expected value"))?;
1275        match token.kind {
1276            TokenKind::String(value) => Ok(Value::String(value)),
1277            TokenKind::Ident(value) if value == "true" => Ok(Value::Boolean(true)),
1278            TokenKind::Ident(value) if value == "false" => Ok(Value::Boolean(false)),
1279            TokenKind::Ident(value) => Ok(Value::Identifier(value)),
1280            TokenKind::Integer(value) => Ok(Value::Integer(value)),
1281            TokenKind::Float(value) => Ok(Value::Float(value)),
1282            TokenKind::Percentage(value) => Ok(Value::Percentage(value)),
1283            _ => Err(ParseError::new(token.column, "expected value")),
1284        }
1285    }
1286
1287    fn expect_duration(&mut self) -> Result<Duration, ParseError> {
1288        let token = self
1289            .advance()
1290            .ok_or_else(|| self.error_here("expected duration such as `5m`"))?;
1291        match token.kind {
1292            TokenKind::Duration(d) => Ok(d),
1293            _ => Err(ParseError::new(
1294                token.column,
1295                "expected duration such as `5m`",
1296            )),
1297        }
1298    }
1299
1300    fn expect_u64(&mut self, context: &str) -> Result<u64, ParseError> {
1301        let token = self
1302            .advance()
1303            .ok_or_else(|| self.error_here(format!("expected integer for {context}")))?;
1304        match token.kind {
1305            TokenKind::Integer(n) if n >= 0 => Ok(n as u64),
1306            TokenKind::Integer(_) => Err(ParseError::new(
1307                token.column,
1308                format!("{context} must be non-negative"),
1309            )),
1310            _ => Err(ParseError::new(
1311                token.column,
1312                format!("expected integer for {context}"),
1313            )),
1314        }
1315    }
1316
1317    fn expect_string(&mut self, context: &str) -> Result<String, ParseError> {
1318        let token = self
1319            .advance()
1320            .ok_or_else(|| self.error_here(format!("expected string for {context}")))?;
1321        match token.kind {
1322            TokenKind::String(s) => Ok(s),
1323            _ => Err(ParseError::new(
1324                token.column,
1325                format!("expected a quoted string for {context}"),
1326            )),
1327        }
1328    }
1329
1330    fn expect_timestamp_string(&mut self) -> Result<String, ParseError> {
1331        let token = self
1332            .advance()
1333            .ok_or_else(|| self.error_here("expected timestamp string"))?;
1334        match token.kind {
1335            TokenKind::String(s) => Ok(s),
1336            _ => Err(ParseError::new(
1337                token.column,
1338                "expected a quoted timestamp string",
1339            ))?,
1340        }
1341    }
1342
1343    fn expect_identifier_like(&mut self, context: &str) -> Result<String, ParseError> {
1344        let token = self
1345            .advance()
1346            .ok_or_else(|| self.error_here(format!("expected {context}")))?;
1347        match token.kind {
1348            TokenKind::Ident(s) | TokenKind::String(s) => Ok(s),
1349            _ => Err(ParseError::new(token.column, format!("expected {context}"))),
1350        }
1351    }
1352
1353    fn expect_ident(&mut self, expected: &str) -> Result<(), ParseError> {
1354        let token = self
1355            .advance()
1356            .ok_or_else(|| self.error_here(format!("expected `{expected}`")))?;
1357        match &token.kind {
1358            TokenKind::Ident(value) if value == expected => Ok(()),
1359            _ => {
1360                let found = token_display(&token);
1361                Err(ParseError::new(
1362                    token.column,
1363                    format!("expected `{expected}`, found `{found}`"),
1364                ))
1365            }
1366        }
1367    }
1368
1369    fn expect(&mut self, kind: &TokenKind, description: &str) -> Result<(), ParseError> {
1370        let token = self
1371            .advance()
1372            .ok_or_else(|| self.error_here(format!("expected {description}")))?;
1373        if token.kind == *kind {
1374            Ok(())
1375        } else {
1376            let found = token_display(&token);
1377            Err(ParseError::new(
1378                token.column,
1379                format!("expected {description}, found `{found}`"),
1380            ))
1381        }
1382    }
1383
1384    fn expect_eof(&self) -> Result<(), ParseError> {
1385        if let Some(token) = self.peek() {
1386            return Err(
1387                ParseError::new(token.column, "unexpected token after query".to_string())
1388                    .with_suggestion(format!(
1389                        "remove or check the `{}` at column {}",
1390                        token_display(token),
1391                        token.column
1392                    )),
1393            );
1394        }
1395        Ok(())
1396    }
1397
1398    fn consume_ident(&mut self, expected: &str) -> bool {
1399        match self.peek().map(|t| &t.kind) {
1400            Some(TokenKind::Ident(v)) if v == expected => {
1401                self.advance();
1402                true
1403            }
1404            _ => false,
1405        }
1406    }
1407
1408    fn consume(&mut self, kind: &TokenKind) -> bool {
1409        match self.peek().map(|t| &t.kind) {
1410            Some(k) if *k == *kind => {
1411                self.advance();
1412                true
1413            }
1414            _ => false,
1415        }
1416    }
1417
1418    fn check(&self, kind: &TokenKind) -> bool {
1419        self.peek().map(|t| &t.kind) == Some(kind)
1420    }
1421
1422    fn is_at_time_or_pipe_or_eof(&self) -> bool {
1423        match self.peek().map(|t| &t.kind) {
1424            None | Some(TokenKind::Pipe) => true,
1425            Some(TokenKind::Ident(v)) if v == "last" || v == "from" => true,
1426            _ => false,
1427        }
1428    }
1429
1430    fn peek(&self) -> Option<&Token> {
1431        self.tokens.get(self.cursor)
1432    }
1433
1434    fn peek_ident(&self) -> Option<&str> {
1435        match self.peek()?.kind {
1436            TokenKind::Ident(ref s) => Some(s.as_str()),
1437            _ => None,
1438        }
1439    }
1440
1441    fn advance(&mut self) -> Option<Token> {
1442        let token = self.tokens.get(self.cursor).cloned()?;
1443        self.cursor += 1;
1444        Some(token)
1445    }
1446
1447    fn error_here(&self, message: impl Into<String>) -> ParseError {
1448        let col = self.peek().map_or(self.eof_column, |t| t.column);
1449        ParseError::new(col, message)
1450    }
1451}
1452
1453/// Return a display-friendly representation of a token for error messages.
1454fn token_display(token: &Token) -> String {
1455    match &token.kind {
1456        TokenKind::Ident(s) => s.clone(),
1457        TokenKind::String(s) => format!("\"{s}\""),
1458        TokenKind::Integer(n) => n.to_string(),
1459        TokenKind::Float(f) => f.to_string(),
1460        TokenKind::Percentage(p) => format!("{p}%"),
1461        TokenKind::Duration(d) => {
1462            let unit = match d.unit {
1463                DurationUnit::Seconds => "s",
1464                DurationUnit::Minutes => "m",
1465                DurationUnit::Hours => "h",
1466                DurationUnit::Days => "d",
1467            };
1468            format!("{}{}", d.value, unit)
1469        }
1470        TokenKind::Pipe => "|".to_owned(),
1471        TokenKind::Comma => ",".to_owned(),
1472        TokenKind::LParen => "(".to_owned(),
1473        TokenKind::RParen => ")".to_owned(),
1474        TokenKind::Plus => "+".to_owned(),
1475        TokenKind::Minus => "-".to_owned(),
1476        TokenKind::Star => "*".to_owned(),
1477        TokenKind::Slash => "/".to_owned(),
1478        TokenKind::Percent => "%".to_owned(),
1479        TokenKind::Eq => "=".to_owned(),
1480        TokenKind::NotEq => "!=".to_owned(),
1481        TokenKind::Gt => ">".to_owned(),
1482        TokenKind::Lt => "<".to_owned(),
1483        TokenKind::Gte => ">=".to_owned(),
1484        TokenKind::Lte => "<=".to_owned(),
1485    }
1486}
1487
1488/// Known pipeline keywords for similarity suggestions.
1489const PIPELINE_KEYWORDS: &[&str] = &[
1490    "where",
1491    "select",
1492    "group",
1493    "having",
1494    "sort",
1495    "limit",
1496    "offset",
1497    "distinct",
1498    "alert",
1499    "if",
1500    "set",
1501    "fill",
1502    "let",
1503    "row_number",
1504    "rank",
1505    "lag",
1506    "lead",
1507];
1508
1509/// Known analysis verbs for similarity suggestions.
1510const ANALYSIS_VERBS: &[&str] = &["find", "correlate", "explain"];
1511
1512/// Known alert actions for similarity suggestions.
1513const ALERT_ACTIONS: &[&str] = &["print", "webhook", "restart", "slack", "discord", "email"];
1514
1515/// Suggest a similar keyword from a known list, using `str_similarity`.
1516fn suggest_keyword(input: &str, keywords: &[&str]) -> Option<String> {
1517    if input.is_empty() {
1518        return None;
1519    }
1520    let first_char = &input[..1];
1521    keywords
1522        .iter()
1523        .filter(|k| k.starts_with(first_char))
1524        .min_by_key(|k| str_similarity(input, k))
1525        .map(|suggestion| format!("did you mean `{suggestion}`?"))
1526}
1527
1528/// Suggest a similar target name when the user types an unknown one.
1529fn suggest_similar_target(input: &str) -> String {
1530    let targets = [
1531        "containers",
1532        "images",
1533        "networks",
1534        "volumes",
1535        "container",
1536        "image",
1537        "network",
1538        "volume",
1539    ];
1540    let suggestion = suggest_keyword(input, &targets);
1541    let base = "try one of: containers, images, networks, volumes";
1542    match suggestion {
1543        Some(s) => format!("{s} {base}"),
1544        None => base.to_owned(),
1545    }
1546}
1547
1548/// Levenshtein edit distance between two strings.
1549/// Lower score = more similar. Uses the `strsim` crate for efficient computation.
1550fn str_similarity(a: &str, b: &str) -> usize {
1551    strsim::levenshtein(a, b)
1552}
1553
1554// ── Tokenizer ──
1555
1556fn tokenize(source: &str) -> Result<Vec<Token>, ParseError> {
1557    let mut tokens = Vec::new();
1558    let chars: Vec<char> = source.chars().collect();
1559    let mut i = 0;
1560
1561    while i < chars.len() {
1562        let col = i + 1;
1563
1564        if chars[i].is_whitespace() {
1565            i += 1;
1566            continue;
1567        }
1568
1569        // Comment
1570        if chars[i] == '#' {
1571            while i < chars.len() && chars[i] != '\n' {
1572                i += 1;
1573            }
1574            continue;
1575        }
1576
1577        // Single-char tokens
1578        match chars[i] {
1579            '|' => {
1580                tokens.push(Token {
1581                    kind: TokenKind::Pipe,
1582                    column: col,
1583                });
1584                i += 1;
1585                continue;
1586            }
1587            ',' => {
1588                tokens.push(Token {
1589                    kind: TokenKind::Comma,
1590                    column: col,
1591                });
1592                i += 1;
1593                continue;
1594            }
1595            '(' => {
1596                tokens.push(Token {
1597                    kind: TokenKind::LParen,
1598                    column: col,
1599                });
1600                i += 1;
1601                continue;
1602            }
1603            ')' => {
1604                tokens.push(Token {
1605                    kind: TokenKind::RParen,
1606                    column: col,
1607                });
1608                i += 1;
1609                continue;
1610            }
1611            '+' => {
1612                tokens.push(Token {
1613                    kind: TokenKind::Plus,
1614                    column: col,
1615                });
1616                i += 1;
1617                continue;
1618            }
1619            '*' => {
1620                tokens.push(Token {
1621                    kind: TokenKind::Star,
1622                    column: col,
1623                });
1624                i += 1;
1625                continue;
1626            }
1627            '/' => {
1628                tokens.push(Token {
1629                    kind: TokenKind::Slash,
1630                    column: col,
1631                });
1632                i += 1;
1633                continue;
1634            }
1635            '%' => {
1636                tokens.push(Token {
1637                    kind: TokenKind::Percent,
1638                    column: col,
1639                });
1640                i += 1;
1641                continue;
1642            }
1643            _ => {}
1644        }
1645
1646        // String
1647        if chars[i] == '"' {
1648            let start = i;
1649            i += 1;
1650            let mut s = String::new();
1651            while i < chars.len() && chars[i] != '"' {
1652                if chars[i] == '\\' && i + 1 < chars.len() {
1653                    i += 1;
1654                    match chars[i] {
1655                        '"' => s.push('"'),
1656                        '\\' => s.push('\\'),
1657                        'n' => s.push('\n'),
1658                        't' => s.push('\t'),
1659                        c => {
1660                            s.push('\\');
1661                            s.push(c);
1662                        }
1663                    }
1664                } else {
1665                    s.push(chars[i]);
1666                }
1667                i += 1;
1668            }
1669            if i >= chars.len() {
1670                return Err(ParseError::new(start + 1, "unterminated string")
1671                    .with_suggestion("add a closing `\"` at the end of the string"));
1672            }
1673            i += 1; // closing "
1674            tokens.push(Token {
1675                kind: TokenKind::String(s),
1676                column: col,
1677            });
1678            continue;
1679        }
1680
1681        // Numbers, percentages, durations, identifiers
1682        if chars[i].is_ascii_digit()
1683            || chars[i] == '-' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit()
1684        {
1685            let start = i;
1686            if chars[i] == '-' {
1687                i += 1;
1688            }
1689            while i < chars.len() && chars[i].is_ascii_digit() {
1690                i += 1;
1691            }
1692            let mut is_float = false;
1693            if i < chars.len() && chars[i] == '.' {
1694                is_float = true;
1695                i += 1;
1696                while i < chars.len() && chars[i].is_ascii_digit() {
1697                    i += 1;
1698                }
1699            }
1700            let num_str: &str = &source[start..i];
1701
1702            if i < chars.len() && chars[i] == '%' {
1703                i += 1;
1704                let val: f64 = num_str
1705                    .parse()
1706                    .map_err(|_| ParseError::new(col, "invalid percentage"))?;
1707                tokens.push(Token {
1708                    kind: TokenKind::Percentage(val),
1709                    column: col,
1710                });
1711            } else if i < chars.len() && matches!(chars[i], 's' | 'm' | 'h' | 'd') {
1712                let unit_char = chars[i];
1713                i += 1;
1714                let val: u64 = num_str
1715                    .parse()
1716                    .map_err(|_| ParseError::new(col, "invalid duration"))?;
1717                let unit = match unit_char {
1718                    's' => DurationUnit::Seconds,
1719                    'm' => DurationUnit::Minutes,
1720                    'h' => DurationUnit::Hours,
1721                    'd' => DurationUnit::Days,
1722                    _ => unreachable!(),
1723                };
1724                tokens.push(Token {
1725                    kind: TokenKind::Duration(Duration { value: val, unit }),
1726                    column: col,
1727                });
1728            } else if is_float {
1729                let val: f64 = num_str
1730                    .parse()
1731                    .map_err(|_| ParseError::new(col, "invalid float"))?;
1732                tokens.push(Token {
1733                    kind: TokenKind::Float(val),
1734                    column: col,
1735                });
1736            } else {
1737                let val: i64 = num_str
1738                    .parse()
1739                    .map_err(|_| ParseError::new(col, "invalid integer"))?;
1740                tokens.push(Token {
1741                    kind: TokenKind::Integer(val),
1742                    column: col,
1743                });
1744            }
1745            continue;
1746        }
1747
1748        // Multi-char operators: >= <= !=
1749        if chars[i] == '>' && i + 1 < chars.len() && chars[i + 1] == '=' {
1750            tokens.push(Token {
1751                kind: TokenKind::Gte,
1752                column: col,
1753            });
1754            i += 2;
1755            continue;
1756        }
1757        if chars[i] == '<' && i + 1 < chars.len() && chars[i + 1] == '=' {
1758            tokens.push(Token {
1759                kind: TokenKind::Lte,
1760                column: col,
1761            });
1762            i += 2;
1763            continue;
1764        }
1765        if chars[i] == '!' && i + 1 < chars.len() && chars[i + 1] == '=' {
1766            tokens.push(Token {
1767                kind: TokenKind::NotEq,
1768                column: col,
1769            });
1770            i += 2;
1771            continue;
1772        }
1773        if chars[i] == '=' {
1774            tokens.push(Token {
1775                kind: TokenKind::Eq,
1776                column: col,
1777            });
1778            i += 1;
1779            continue;
1780        }
1781        if chars[i] == '>' {
1782            tokens.push(Token {
1783                kind: TokenKind::Gt,
1784                column: col,
1785            });
1786            i += 1;
1787            continue;
1788        }
1789        if chars[i] == '<' {
1790            tokens.push(Token {
1791                kind: TokenKind::Lt,
1792                column: col,
1793            });
1794            i += 1;
1795            continue;
1796        }
1797        if chars[i] == '-' && i + 1 < chars.len() && chars[i + 1] == '>' {
1798            tokens.push(Token {
1799                kind: TokenKind::Pipe,
1800                column: col,
1801            });
1802            i += 2;
1803            continue;
1804        }
1805
1806        // Standalone minus (not part of a number, not ->)
1807        if chars[i] == '-' {
1808            tokens.push(Token {
1809                kind: TokenKind::Minus,
1810                column: col,
1811            });
1812            i += 1;
1813            continue;
1814        }
1815
1816        // Identifier
1817        if chars[i].is_ascii_alphanumeric()
1818            || chars[i] == '_'
1819            || chars[i] == '.'
1820            || chars[i] == ':'
1821            || chars[i] == '@'
1822            || chars[i] == '/'
1823            || chars[i] == '$'
1824        {
1825            let start = i;
1826            while i < chars.len()
1827                && (chars[i].is_ascii_alphanumeric()
1828                    || chars[i] == '_'
1829                    || chars[i] == '.'
1830                    || chars[i] == ':'
1831                    || chars[i] == '@'
1832                    || chars[i] == '/'
1833                    || chars[i] == '-'
1834                    || chars[i] == '$')
1835            {
1836                i += 1;
1837            }
1838            tokens.push(Token {
1839                kind: TokenKind::Ident(source[start..i].to_owned()),
1840                column: col,
1841            });
1842            continue;
1843        }
1844
1845        return Err(
1846            ParseError::new(col, format!("unexpected character `{}`", chars[i]))
1847                .with_suggestion("expected letters, numbers, `_`, `.`, `:`, `@`, `/`, `-`, or `$`"),
1848        );
1849    }
1850
1851    Ok(tokens)
1852}
1853
1854// ── Parse tests remain the same ──
1855// Note: the inline where + pipeline test cases all produce the same AST shapes
1856// because the parser still emits the same high-level Query/Expression types.
1857// The internal structure changes (Field vs field string) but the tests
1858// only check high-level shapes (matches! patterns) which are preserved.
1859
1860#[cfg(test)]
1861mod tests {
1862    use super::*;
1863
1864    fn parse_one(query: &str) -> ParsedQuery {
1865        parse(query).unwrap()
1866    }
1867
1868    #[test]
1869    fn parses_observe_containers() {
1870        let q = parse_one("observe containers");
1871        assert!(matches!(q.query, Query::Observe(_)));
1872    }
1873
1874    #[test]
1875    fn parses_inline_where() {
1876        let q = parse_one("observe containers where status = running");
1877        match q.query {
1878            Query::Observe(ref o) => assert!(o.filter.is_some()),
1879            _ => panic!("expected Observe"),
1880        }
1881    }
1882
1883    #[test]
1884    fn parses_pipe_query() {
1885        let q = parse_one(
1886            "observe containers | where cpu > 80% | select name, image, cpu | sort cpu desc | limit 10",
1887        );
1888        assert!(matches!(q.query, Query::Observe(_)));
1889    }
1890
1891    #[test]
1892    fn parses_fields_containers() {
1893        let q = parse_one("fields containers");
1894        assert!(matches!(
1895            q.query,
1896            Query::Fields(CollectionTarget::Containers)
1897        ));
1898    }
1899
1900    #[test]
1901    fn parses_fields_images() {
1902        let q = parse_one("fields images");
1903        assert!(matches!(q.query, Query::Fields(CollectionTarget::Images)));
1904    }
1905
1906    #[test]
1907    fn parses_events_query() {
1908        let q = parse_one("events containers");
1909        assert!(matches!(q.query, Query::Events(_)));
1910    }
1911
1912    #[test]
1913    fn parses_inspect_at_query() {
1914        let q = parse_one(r#"inspect container api-service at "2026-01-01 12:00:00""#);
1915        assert!(matches!(q.query, Query::Inspect(_)));
1916    }
1917
1918    #[test]
1919    fn parses_alert_query() {
1920        let q = parse_one(r#"alert when cpu > 85% for 2m then print "High CPU""#);
1921        assert!(matches!(q.query, Query::Alert(_)));
1922    }
1923
1924    #[test]
1925    fn parses_alert_slack_action() {
1926        let q =
1927            parse_one(r#"alert when cpu > 80% then slack "https://hooks.slack.com/services/xxx""#);
1928        let Query::Alert(ref rule) = q.query else {
1929            panic!("expected Alert")
1930        };
1931        assert!(matches!(rule.action, AlertAction::Slack(_)));
1932        if let AlertAction::Slack(ref url) = rule.action {
1933            assert_eq!(url, "https://hooks.slack.com/services/xxx");
1934        }
1935    }
1936
1937    #[test]
1938    fn parses_alert_discord_action() {
1939        let q = parse_one(
1940            r#"alert when cpu > 80% then discord "https://discord.com/api/webhooks/xxx""#,
1941        );
1942        let Query::Alert(ref rule) = q.query else {
1943            panic!("expected Alert")
1944        };
1945        assert!(matches!(rule.action, AlertAction::Discord(_)));
1946        if let AlertAction::Discord(ref url) = rule.action {
1947            assert_eq!(url, "https://discord.com/api/webhooks/xxx");
1948        }
1949    }
1950
1951    #[test]
1952    fn parses_alert_email_action() {
1953        let q = parse_one(r#"alert when cpu > 80% then email "admin@example.com""#);
1954        let Query::Alert(ref rule) = q.query else {
1955            panic!("expected Alert")
1956        };
1957        assert!(matches!(rule.action, AlertAction::Email { .. }));
1958        if let AlertAction::Email {
1959            ref to,
1960            ref subject,
1961        } = rule.action
1962        {
1963            assert_eq!(to, "admin@example.com");
1964            assert_eq!(subject, "DockQL Alert Notification");
1965        }
1966    }
1967
1968    #[test]
1969    fn parses_alert_email_with_custom_subject() {
1970        let q = parse_one(
1971            r#"alert when cpu > 80% then email "admin@example.com" subject "High CPU Alert""#,
1972        );
1973        let Query::Alert(ref rule) = q.query else {
1974            panic!("expected Alert")
1975        };
1976        if let AlertAction::Email {
1977            ref to,
1978            ref subject,
1979        } = rule.action
1980        {
1981            assert_eq!(to, "admin@example.com");
1982            assert_eq!(subject, "High CPU Alert");
1983        }
1984    }
1985
1986    #[test]
1987    fn parses_analyze_query() {
1988        let q = parse_one("analyze containers find anomalies");
1989        assert!(matches!(q.query, Query::Analyze(_)));
1990    }
1991
1992    #[test]
1993    fn parses_sort_and_limit() {
1994        let q = parse_one("observe containers | sort by state desc | limit 5");
1995        if let Query::Observe(ref o) = q.query {
1996            assert!(matches!(o.pipeline[0], PipelineNode::SortBy { .. }));
1997            assert!(matches!(o.pipeline[1], PipelineNode::Limit(5)));
1998        } else {
1999            panic!("expected Observe");
2000        }
2001    }
2002
2003    #[test]
2004    fn parses_boolean_precedence() {
2005        let q =
2006            parse_one("observe containers where status = running and (cpu > 80% or memory > 90%)");
2007        let Query::Observe(ref o) = q.query else {
2008            panic!("expected Observe")
2009        };
2010        let filter = o.filter.as_ref().expect("expected filter");
2011        assert!(matches!(filter, Expression::And(_, _)));
2012    }
2013
2014    #[test]
2015    fn parses_inline_where_expression() {
2016        let q = parse_one("observe containers where status = running and cpu > 80%");
2017        let Query::Observe(ref o) = q.query else {
2018            panic!("expected Observe")
2019        };
2020        assert!(matches!(o.filter, Some(Expression::And(_, _))));
2021    }
2022
2023    #[test]
2024    fn parses_if_then_pipeline() {
2025        let q = parse_one(r#"observe containers | if cpu > 90% then alert "Critical CPU""#);
2026        let Query::Observe(ref o) = q.query else {
2027            panic!("expected Observe")
2028        };
2029        assert!(matches!(o.pipeline[0], PipelineNode::If { .. }));
2030    }
2031
2032    #[test]
2033    fn parses_if_then_else_if() {
2034        let q = parse_one(
2035            r#"observe containers | if cpu > 90% then alert "Critical" else if cpu > 70% then alert "Warning""#,
2036        );
2037        let Query::Observe(ref o) = q.query else {
2038            panic!("expected Observe")
2039        };
2040        assert!(matches!(o.pipeline[0], PipelineNode::If { .. }));
2041    }
2042
2043    #[test]
2044    fn parses_set_literal() {
2045        let q = parse_one(r#"observe containers | set tier = "prod""#);
2046        let Query::Observe(ref o) = q.query else {
2047            panic!("expected Observe")
2048        };
2049        assert!(matches!(o.pipeline[0], PipelineNode::Set { .. }));
2050    }
2051
2052    #[test]
2053    fn parses_set_case_when() {
2054        let q = parse_one(
2055            r#"observe containers | set severity = case when cpu > 80% then "critical" else "ok" end"#,
2056        );
2057        let Query::Observe(ref o) = q.query else {
2058            panic!("expected Observe")
2059        };
2060        match &o.pipeline[0] {
2061            PipelineNode::Set { value, .. } => assert!(matches!(value, SetValue::Case { .. })),
2062            _ => panic!("expected Set with Case"),
2063        }
2064    }
2065
2066    #[test]
2067    fn parses_set_if_else() {
2068        let q = parse_one(
2069            r#"observe containers | set health = if state = running then "up" else "down""#,
2070        );
2071        let Query::Observe(ref o) = q.query else {
2072            panic!("expected Observe")
2073        };
2074        match &o.pipeline[0] {
2075            PipelineNode::Set { value, .. } => assert!(matches!(value, SetValue::IfElse { .. })),
2076            _ => panic!("expected Set with IfElse"),
2077        }
2078    }
2079
2080    #[test]
2081    fn parses_in_operator() {
2082        let q = parse_one(r#"observe containers | where image in ("nginx", "redis")"#);
2083        let Query::Observe(ref o) = q.query else {
2084            panic!("expected Observe")
2085        };
2086        assert!(matches!(
2087            o.pipeline[0],
2088            PipelineNode::Where(Expression::In { .. })
2089        ));
2090    }
2091
2092    #[test]
2093    fn rejects_empty_query() {
2094        assert!(parse("").is_err());
2095        assert!(parse("   ").is_err());
2096    }
2097
2098    #[test]
2099    fn reports_position_for_bad_where() {
2100        let err = parse("observe containers where").unwrap_err();
2101        assert!(err.column >= 22);
2102    }
2103
2104    #[test]
2105    fn shows_context_with_pointer() {
2106        // Query with a trailing `where` (expects expression, finds EOF)
2107        let err = parse("observe containers where").unwrap_err();
2108        let msg = err.to_string();
2109        assert!(msg.contains("-->"));
2110        assert!(
2111            msg.contains("^"),
2112            "error display should have a pointer: {msg}"
2113        );
2114        assert!(
2115            msg.contains("observe containers where"),
2116            "error display should show the source query: {msg}"
2117        );
2118    }
2119
2120    #[test]
2121    fn shows_context_with_pointer_at_pipe() {
2122        // Query with `where` followed by pipe (expects expression, finds pipe)
2123        let err = parse("observe containers | where | sort by cpu").unwrap_err();
2124        let msg = err.to_string();
2125        assert!(msg.contains("-->"));
2126        assert!(
2127            msg.contains("^"),
2128            "error display should have a pointer: {msg}"
2129        );
2130        assert!(
2131            msg.contains("observe containers | where | sort by cpu"),
2132            "error display should show the source query: {msg}"
2133        );
2134        // The pointer should be at or after the `where` keyword
2135        let pointer_pos = msg.find('^').unwrap();
2136        let source_pos = msg.find("where |").unwrap_or(msg.len());
2137        assert!(
2138            pointer_pos > source_pos,
2139            "pointer should be after `where |`: {msg}"
2140        );
2141    }
2142
2143    #[test]
2144    fn all_example_files_parse() {
2145        use std::path::Path;
2146        let examples_dir = Path::new("examples");
2147        if !examples_dir.exists() {
2148            return;
2149        }
2150        let mut count = 0;
2151        for entry in std::fs::read_dir(examples_dir).unwrap() {
2152            let entry = entry.unwrap();
2153            if entry
2154                .path()
2155                .extension()
2156                .map(|e| e == "dol")
2157                .unwrap_or(false)
2158            {
2159                let content = std::fs::read_to_string(entry.path()).unwrap();
2160                let result = parse(&content);
2161                if let Err(ref e) = result {
2162                    panic!("Failed to parse {}: {}", entry.path().display(), e);
2163                }
2164                count += 1;
2165            }
2166        }
2167        assert!(count > 0, "no example files found");
2168    }
2169
2170    // ── New Tier 1 parse tests ──
2171
2172    #[test]
2173    fn parses_arithmetic_expression() {
2174        let q = parse_one(r#"observe containers | set mem_gb = memory / 1073741824"#);
2175        let Query::Observe(ref o) = q.query else {
2176            panic!("expected Observe")
2177        };
2178        match &o.pipeline[0] {
2179            PipelineNode::Set { value, .. } => {
2180                assert!(matches!(
2181                    value,
2182                    SetValue::Expr(Expression::Arithmetic { op: BinOp::Div, .. })
2183                ));
2184            }
2185            _ => panic!("expected Set with Expr"),
2186        }
2187    }
2188
2189    #[test]
2190    fn parses_between() {
2191        let q = parse_one("observe containers where cpu between 50 and 80");
2192        let Query::Observe(ref o) = q.query else {
2193            panic!("expected Observe")
2194        };
2195        let filter = o.filter.as_ref().expect("expected filter");
2196        assert!(matches!(filter, Expression::Between { .. }));
2197    }
2198
2199    #[test]
2200    fn parses_is_null() {
2201        let q = parse_one("observe containers where finished_at is null");
2202        let Query::Observe(ref o) = q.query else {
2203            panic!("expected Observe")
2204        };
2205        let filter = o.filter.as_ref().expect("expected filter");
2206        assert!(matches!(filter, Expression::IsNull(_)));
2207    }
2208
2209    #[test]
2210    fn parses_is_not_null() {
2211        let q = parse_one("observe containers where finished_at is not null");
2212        let Query::Observe(ref o) = q.query else {
2213            panic!("expected Observe")
2214        };
2215        let filter = o.filter.as_ref().expect("expected filter");
2216        assert!(matches!(filter, Expression::IsNotNull(_)));
2217    }
2218
2219    #[test]
2220    fn parses_function_call() {
2221        let q = parse_one("observe containers | where upper(name) contains \"API\"");
2222        let Query::Observe(ref o) = q.query else {
2223            panic!("expected Observe")
2224        };
2225        match &o.pipeline[0] {
2226            PipelineNode::Where(Expression::Comparison { left, .. }) => {
2227                assert!(
2228                    matches!(left.as_ref(), Expression::FnCall { name, .. } if name == "upper")
2229                );
2230            }
2231            _ => panic!("expected FnCall comparison"),
2232        }
2233    }
2234
2235    #[test]
2236    fn parses_multi_field_sort() {
2237        let q = parse_one("observe containers | sort by state desc, cpu desc");
2238        let Query::Observe(ref o) = q.query else {
2239            panic!("expected Observe")
2240        };
2241        match &o.pipeline[0] {
2242            PipelineNode::SortBy { fields } => {
2243                assert_eq!(fields.len(), 2);
2244                assert_eq!(fields[0].0, "state");
2245                assert_eq!(fields[0].1, SortDirection::Desc);
2246                assert_eq!(fields[1].0, "cpu");
2247            }
2248            _ => panic!("expected SortBy"),
2249        }
2250    }
2251
2252    #[test]
2253    fn parses_distinct() {
2254        let q = parse_one("observe containers | distinct | select image");
2255        let Query::Observe(ref o) = q.query else {
2256            panic!("expected Observe")
2257        };
2258        assert!(matches!(o.pipeline[0], PipelineNode::Distinct));
2259    }
2260
2261    #[test]
2262    fn parses_compose_query() {
2263        let q = parse_one("compose myapp");
2264        assert!(matches!(q.query, Query::Compose(_)));
2265        if let Query::Compose(ref c) = q.query {
2266            assert_eq!(c.project, "myapp");
2267            assert_eq!(c.target, ComposeTarget::Containers);
2268            assert!(c.pipeline.is_empty());
2269        }
2270    }
2271
2272    #[test]
2273    fn parses_compose_services() {
2274        let q = parse_one("compose myapp services");
2275        assert!(matches!(q.query, Query::Compose(_)));
2276        if let Query::Compose(ref c) = q.query {
2277            assert_eq!(c.project, "myapp");
2278            assert_eq!(c.target, ComposeTarget::Services);
2279        }
2280    }
2281
2282    #[test]
2283    fn parses_observe_compose() {
2284        let q = parse_one("observe compose myapp");
2285        assert!(matches!(q.query, Query::Compose(_)));
2286        if let Query::Compose(ref c) = q.query {
2287            assert_eq!(c.project, "myapp");
2288            assert_eq!(c.target, ComposeTarget::Containers);
2289        }
2290    }
2291
2292    #[test]
2293    fn parses_compose_with_pipeline() {
2294        let q = parse_one("compose myapp | where cpu > 80% | select name, cpu");
2295        assert!(matches!(q.query, Query::Compose(_)));
2296        if let Query::Compose(ref c) = q.query {
2297            assert_eq!(c.project, "myapp");
2298            assert_eq!(c.pipeline.len(), 2);
2299        }
2300    }
2301
2302    #[test]
2303    fn parses_compose_containers_explicit() {
2304        let q = parse_one("compose myapp containers");
2305        if let Query::Compose(ref c) = q.query {
2306            assert_eq!(c.project, "myapp");
2307            assert_eq!(c.target, ComposeTarget::Containers);
2308        } else {
2309            panic!("expected Compose");
2310        }
2311    }
2312
2313    #[test]
2314    fn parses_observe_compose_services() {
2315        let q = parse_one("observe compose myapp services");
2316        if let Query::Compose(ref c) = q.query {
2317            assert_eq!(c.project, "myapp");
2318            assert_eq!(c.target, ComposeTarget::Services);
2319        } else {
2320            panic!("expected Compose");
2321        }
2322    }
2323
2324    #[test]
2325    fn parses_compose_with_sort_limit() {
2326        let q = parse_one("compose myapp services | sort by name asc | limit 5");
2327        if let Query::Compose(ref c) = q.query {
2328            assert_eq!(c.project, "myapp");
2329            assert_eq!(c.target, ComposeTarget::Services);
2330            assert_eq!(c.pipeline.len(), 2);
2331        } else {
2332            panic!("expected Compose");
2333        }
2334    }
2335
2336    #[test]
2337    fn parses_compose_with_group_by() {
2338        let q = parse_one("compose myapp | group by state with count(id) as cnt");
2339        if let Query::Compose(ref c) = q.query {
2340            assert_eq!(c.pipeline.len(), 1);
2341            assert!(matches!(c.pipeline[0], PipelineNode::GroupBy { .. }));
2342        } else {
2343            panic!("expected Compose");
2344        }
2345    }
2346
2347    #[test]
2348    fn parses_compose_with_where_and_select() {
2349        let q = parse_one("compose myapp | where state = \"running\" | select name, image, state");
2350        if let Query::Compose(ref c) = q.query {
2351            assert_eq!(c.pipeline.len(), 2);
2352            assert!(matches!(c.pipeline[0], PipelineNode::Where(_)));
2353            assert!(matches!(c.pipeline[1], PipelineNode::Select(_)));
2354        } else {
2355            panic!("expected Compose");
2356        }
2357    }
2358
2359    #[test]
2360    fn parses_compose_with_distinct() {
2361        let q = parse_one("compose myapp | select image | distinct");
2362        if let Query::Compose(ref c) = q.query {
2363            assert_eq!(c.pipeline.len(), 2);
2364            assert!(matches!(c.pipeline[1], PipelineNode::Distinct));
2365        } else {
2366            panic!("expected Compose");
2367        }
2368    }
2369
2370    #[test]
2371    fn parses_compose_hyphenated_project() {
2372        let q = parse_one("compose my-app-v2");
2373        if let Query::Compose(ref c) = q.query {
2374            assert_eq!(c.project, "my-app-v2");
2375        } else {
2376            panic!("expected Compose");
2377        }
2378    }
2379
2380    #[test]
2381    fn rejects_compose_without_project() {
2382        let result = parse("compose");
2383        assert!(result.is_err());
2384    }
2385
2386    #[test]
2387    fn parses_compose_networks() {
2388        let q = parse_one("compose myapp networks");
2389        if let Query::Compose(ref c) = q.query {
2390            assert_eq!(c.project, "myapp");
2391            assert_eq!(c.target, ComposeTarget::Networks);
2392        } else {
2393            panic!("expected Compose");
2394        }
2395    }
2396
2397    #[test]
2398    fn parses_compose_volumes() {
2399        let q = parse_one("compose myapp volumes");
2400        if let Query::Compose(ref c) = q.query {
2401            assert_eq!(c.project, "myapp");
2402            assert_eq!(c.target, ComposeTarget::Volumes);
2403        } else {
2404            panic!("expected Compose");
2405        }
2406    }
2407
2408    #[test]
2409    fn parses_compose_health() {
2410        let q = parse_one("compose myapp health");
2411        if let Query::Compose(ref c) = q.query {
2412            assert_eq!(c.project, "myapp");
2413            assert_eq!(c.target, ComposeTarget::Health);
2414        } else {
2415            panic!("expected Compose");
2416        }
2417    }
2418
2419    #[test]
2420    fn parses_observe_compose_networks() {
2421        let q = parse_one("observe compose myapp networks | select name, driver");
2422        if let Query::Compose(ref c) = q.query {
2423            assert_eq!(c.project, "myapp");
2424            assert_eq!(c.target, ComposeTarget::Networks);
2425            assert_eq!(c.pipeline.len(), 1);
2426        } else {
2427            panic!("expected Compose");
2428        }
2429    }
2430
2431    #[test]
2432    fn parses_observe_compose_health() {
2433        let q = parse_one("observe compose myapp health");
2434        if let Query::Compose(ref c) = q.query {
2435            assert_eq!(c.project, "myapp");
2436            assert_eq!(c.target, ComposeTarget::Health);
2437        } else {
2438            panic!("expected Compose");
2439        }
2440    }
2441
2442    #[test]
2443    fn parses_offset() {
2444        let q = parse_one("observe containers | sort by name asc | offset 10 | limit 5");
2445        let Query::Observe(ref o) = q.query else {
2446            panic!("expected Observe")
2447        };
2448        assert!(matches!(o.pipeline[1], PipelineNode::Offset(10)));
2449    }
2450
2451    #[test]
2452    fn parses_having() {
2453        let q =
2454            parse_one("observe containers | group by image with count(id) as cnt | having cnt > 3");
2455        let Query::Observe(ref o) = q.query else {
2456            panic!("expected Observe")
2457        };
2458        assert!(matches!(o.pipeline[1], PipelineNode::Having(_)));
2459    }
2460
2461    #[test]
2462    fn parses_observe_join_networks() {
2463        let q = parse_one("observe containers join networks on id = containers");
2464        let Query::Observe(ref o) = q.query else {
2465            panic!("expected Observe")
2466        };
2467        let join = o.join.as_ref().expect("expected join clause");
2468        assert_eq!(join.right, CollectionTarget::Networks);
2469    }
2470
2471    #[test]
2472    fn parses_observe_join_images() {
2473        let q = parse_one("observe containers join images on image = name");
2474        let Query::Observe(ref o) = q.query else {
2475            panic!("expected Observe")
2476        };
2477        assert!(o.join.is_some());
2478        assert_eq!(o.join.as_ref().unwrap().right, CollectionTarget::Images);
2479    }
2480
2481    #[test]
2482    fn parses_observe_join_with_pipeline() {
2483        let q = parse_one(
2484            "observe containers join networks on id = containers | select c.name, n.name",
2485        );
2486        let Query::Observe(ref o) = q.query else {
2487            panic!("expected Observe")
2488        };
2489        assert!(o.join.is_some());
2490        assert_eq!(o.pipeline.len(), 1);
2491        assert!(matches!(o.pipeline[0], PipelineNode::Select(_)));
2492    }
2493
2494    #[test]
2495    fn rejects_join_without_on() {
2496        let result = parse("observe containers join networks");
2497        assert!(result.is_err());
2498    }
2499
2500    #[test]
2501    fn parses_fill_pipeline() {
2502        let q = parse_one("observe containers | fill memory with 0");
2503        let Query::Observe(ref o) = q.query else {
2504            panic!("expected Observe")
2505        };
2506        assert!(matches!(o.pipeline[0], PipelineNode::Fill { .. }));
2507    }
2508
2509    #[test]
2510    fn parses_fill_with_expression() {
2511        let q = parse_one("observe containers | fill name with coalesce(label.name, name)");
2512        let Query::Observe(ref o) = q.query else {
2513            panic!("expected Observe")
2514        };
2515        assert!(matches!(o.pipeline[0], PipelineNode::Fill { .. }));
2516    }
2517
2518    #[test]
2519    fn parses_starts_with_operator() {
2520        let q = parse_one("observe containers where name starts_with \"api-\"");
2521        let Query::Observe(ref o) = q.query else {
2522            panic!("expected Observe")
2523        };
2524        let filter = o.filter.as_ref().expect("expected filter");
2525        assert!(matches!(
2526            filter,
2527            Expression::Comparison {
2528                operator: Operator::StartsWith,
2529                ..
2530            }
2531        ));
2532    }
2533
2534    #[test]
2535    fn parses_ends_with_operator() {
2536        let q = parse_one("observe containers where image ends_with \":latest\"");
2537        let Query::Observe(ref o) = q.query else {
2538            panic!("expected Observe")
2539        };
2540        let filter = o.filter.as_ref().expect("expected filter");
2541        assert!(matches!(
2542            filter,
2543            Expression::Comparison {
2544                operator: Operator::EndsWith,
2545                ..
2546            }
2547        ));
2548    }
2549
2550    #[test]
2551    fn parses_let_pipeline() {
2552        let q = parse_one("observe containers | let $threshold = 80");
2553        let Query::Observe(ref o) = q.query else {
2554            panic!("expected Observe")
2555        };
2556        match &o.pipeline[0] {
2557            PipelineNode::Let { name, .. } => assert_eq!(name, "threshold"),
2558            other => panic!("expected Let, got {other:?}"),
2559        }
2560    }
2561
2562    #[test]
2563    fn parses_let_without_dollar() {
2564        let q = parse_one("observe containers | let threshold = 80");
2565        let Query::Observe(ref o) = q.query else {
2566            panic!("expected Observe")
2567        };
2568        match &o.pipeline[0] {
2569            PipelineNode::Let { name, .. } => assert_eq!(name, "threshold"),
2570            other => panic!("expected Let, got {other:?}"),
2571        }
2572    }
2573
2574    #[test]
2575    fn parses_let_with_string_value() {
2576        let q = parse_one(r#"observe containers | let $app_name = "myapp""#);
2577        let Query::Observe(ref o) = q.query else {
2578            panic!("expected Observe")
2579        };
2580        match &o.pipeline[0] {
2581            PipelineNode::Let { name, value } => {
2582                assert_eq!(name, "app_name");
2583                assert!(matches!(value, Expression::Literal(Value::String(s)) if s == "myapp"));
2584            }
2585            other => panic!("expected Let, got {other:?}"),
2586        }
2587    }
2588
2589    #[test]
2590    fn parses_let_with_expression() {
2591        let q = parse_one("observe containers | let $limit = cpu * 2");
2592        let Query::Observe(ref o) = q.query else {
2593            panic!("expected Observe")
2594        };
2595        match &o.pipeline[0] {
2596            PipelineNode::Let { name, value } => {
2597                assert_eq!(name, "limit");
2598                assert!(matches!(value, Expression::Arithmetic { .. }));
2599            }
2600            other => panic!("expected Let, got {other:?}"),
2601        }
2602    }
2603
2604    #[test]
2605    fn parses_dollar_var_as_field() {
2606        let q = parse_one("observe containers where $state = running");
2607        let Query::Observe(ref o) = q.query else {
2608            panic!("expected Observe")
2609        };
2610        let filter = o.filter.as_ref().expect("expected filter");
2611        match filter {
2612            Expression::Comparison { left, .. } => {
2613                assert!(matches!(left.as_ref(), Expression::Field(f) if f == "state"));
2614            }
2615            _ => panic!("expected comparison"),
2616        }
2617    }
2618
2619    // ── Error message improvement tests ──
2620
2621    #[test]
2622    fn empty_query_has_suggestion() {
2623        let err = parse("").unwrap_err();
2624        assert_eq!(err.column, 1);
2625        assert_eq!(err.message, "empty DOL query");
2626        assert!(err.suggestion.is_some());
2627        let sug = err.suggestion.as_ref().unwrap();
2628        assert!(
2629            sug.contains("observe containers"),
2630            "suggestion should mention `observe containers`: {sug}"
2631        );
2632    }
2633
2634    #[test]
2635    fn whitespace_query_has_suggestion() {
2636        let err = parse("   ").unwrap_err();
2637        assert_eq!(err.message, "empty DOL query");
2638        assert!(err.suggestion.is_some());
2639    }
2640
2641    #[test]
2642    fn suggestion_appears_in_display() {
2643        let err = parse("").unwrap_err();
2644        let msg = err.to_string();
2645        assert!(
2646            msg.contains("help:"),
2647            "Display should include help text: {msg}"
2648        );
2649        assert!(
2650            msg.contains("observe containers"),
2651            "Display should include suggestion: {msg}"
2652        );
2653    }
2654
2655    #[test]
2656    fn format_color_contains_ansi_escapes() {
2657        let err = parse("").unwrap_err();
2658        let colored = err.format_color();
2659        // ANSI escape code starts with \x1b (ESC character)
2660        assert!(
2661            colored.contains('\x1b'),
2662            "format_color should contain ANSI escapes"
2663        );
2664        assert!(
2665            colored.contains("error"),
2666            "format_color should contain error label"
2667        );
2668        assert!(
2669            colored.contains("help:"),
2670            "format_color should contain help text"
2671        );
2672        // Should NOT contain the Display prefix "parse error" (it uses "error" in bold red)
2673        assert!(
2674            !colored.starts_with("parse error"),
2675            "format_color should not use Display's plain prefix"
2676        );
2677    }
2678
2679    #[test]
2680    fn format_color_source_pointer_has_ansi() {
2681        let err = parse("observe containers where").unwrap_err();
2682        let colored = err.format_color();
2683        assert!(colored.contains('\x1b'), "should contain ANSI");
2684        assert!(colored.contains("-->"), "should show source pointer");
2685        assert!(colored.contains('^'), "should show caret");
2686    }
2687
2688    #[test]
2689    fn parse_error_with_source_has_pointer_and_suggestion() {
2690        // Create a ParseError manually and chain builders
2691        let err = ParseError::new(5, "test error")
2692            .with_source("observe containers".to_owned())
2693            .with_suggestion("try something else");
2694
2695        assert_eq!(err.column, 5);
2696        assert_eq!(err.message, "test error");
2697        assert_eq!(err.source.as_deref(), Some("observe containers"));
2698        assert_eq!(err.suggestion.as_deref(), Some("try something else"));
2699
2700        let msg = err.to_string();
2701        assert!(msg.contains("parse error at column 5"));
2702        assert!(msg.contains("-->"));
2703        assert!(msg.contains("help: try something else"));
2704    }
2705
2706    #[test]
2707    fn unknown_query_family_has_suggestion() {
2708        let err = parse("watc containers").unwrap_err();
2709        assert!(err.suggestion.is_some());
2710        let sug = err.suggestion.as_ref().unwrap();
2711        assert!(
2712            sug.contains("try one of"),
2713            "suggestion should list alternatives: {sug}"
2714        );
2715        assert!(
2716            sug.contains("observe"),
2717            "suggestion should mention `observe`: {sug}"
2718        );
2719    }
2720
2721    #[test]
2722    fn unknown_collection_target_has_suggestion() {
2723        let err = parse("observe containerz").unwrap_err();
2724        assert!(err.suggestion.is_some(), "should have a suggestion");
2725        let sug = err.suggestion.as_ref().unwrap();
2726        assert!(
2727            sug.contains("containers") || sug.contains("try one of"),
2728            "suggestion should guide to valid targets: {sug}"
2729        );
2730    }
2731
2732    #[test]
2733    fn partial_collection_target_gets_similar_suggestion() {
2734        // "containers" misspelled as "conts"
2735        let err = parse("observe conts").unwrap_err();
2736        assert!(err.suggestion.is_some());
2737        let sug = err.suggestion.as_ref().unwrap();
2738        assert!(
2739            sug.contains("containers"),
2740            "suggestion should suggest `containers`: {sug}"
2741        );
2742    }
2743
2744    #[test]
2745    fn partial_singular_target_gets_similar_suggestion() {
2746        // "container" misspelled as "cont"
2747        let err = parse("inspect cont my-container").unwrap_err();
2748        assert!(err.suggestion.is_some());
2749        let sug = err.suggestion.as_ref().unwrap();
2750        assert!(
2751            sug.contains("container"),
2752            "suggestion should suggest `container`: {sug}"
2753        );
2754    }
2755
2756    #[test]
2757    fn unknown_pipeline_node_has_suggestion() {
2758        let err = parse("observe containers | fltr name = test").unwrap_err();
2759        assert!(err.suggestion.is_some());
2760        let sug = err.suggestion.as_ref().unwrap();
2761        assert!(
2762            sug.contains("where") || sug.contains("select"),
2763            "suggestion should list pipeline options: {sug}"
2764        );
2765    }
2766
2767    #[test]
2768    fn unknown_analysis_verb_has_suggestion() {
2769        let err = parse("analyze containers delet").unwrap_err();
2770        assert!(err.suggestion.is_some());
2771        let sug = err.suggestion.as_ref().unwrap();
2772        assert!(
2773            sug.contains("find") || sug.contains("correlate") || sug.contains("explain"),
2774            "suggestion should list analysis verbs: {sug}"
2775        );
2776    }
2777
2778    #[test]
2779    fn unknown_alert_action_has_suggestion() {
2780        let err = parse(r#"alert when cpu > 80% then emai "alert""#).unwrap_err();
2781        assert!(err.suggestion.is_some());
2782        let sug = err.suggestion.as_ref().unwrap();
2783        assert!(
2784            sug.contains("print") || sug.contains("webhook") || sug.contains("restart"),
2785            "suggestion should list alert actions: {sug}"
2786        );
2787    }
2788
2789    #[test]
2790    fn unterminated_string_has_suggestion() {
2791        let err = parse(r#"observe containers where name = "unfinished"#).unwrap_err();
2792        assert!(err.suggestion.is_some());
2793        let sug = err.suggestion.as_ref().unwrap();
2794        assert!(
2795            sug.contains("closing"),
2796            "suggestion should mention closing quote: {sug}"
2797        );
2798    }
2799
2800    #[test]
2801    fn unexpected_character_has_suggestion() {
2802        let err = parse("observe containers | where cpu > 80% | select name~image").unwrap_err();
2803        assert!(err.suggestion.is_some());
2804        let sug = err.suggestion.as_ref().unwrap();
2805        assert!(
2806            sug.contains("expected letters"),
2807            "suggestion should describe valid characters: {sug}"
2808        );
2809    }
2810
2811    #[test]
2812    fn unexpected_token_after_query_has_suggestion() {
2813        let err = parse("ping extra").unwrap_err();
2814        assert!(err.suggestion.is_some());
2815        let sug = err.suggestion.as_ref().unwrap();
2816        assert!(
2817            sug.contains("remove") || sug.contains("check"),
2818            "suggestion should mention remove/check: {sug}"
2819        );
2820        assert!(
2821            sug.contains("extra"),
2822            "suggestion should mention the unexpected token: {sug}"
2823        );
2824    }
2825
2826    #[test]
2827    fn multiple_suggestions_not_crashing() {
2828        // Parsing a completely invalid input should still produce a helpful error
2829        // without panicking.
2830        let err = parse("!@#$%^&").unwrap_err();
2831        assert!(err.suggestion.is_some());
2832        let msg = err.to_string();
2833        assert!(!msg.is_empty(), "error message should not be empty");
2834        // format_color() should also not panic
2835        let colored = err.format_color();
2836        assert!(!colored.is_empty(), "colored output should not be empty");
2837    }
2838
2839    #[test]
2840    fn error_contains_source_column_pointer() {
2841        // Verify the Display output includes the source line and column pointer
2842        let err = parse("observe containers where").unwrap_err();
2843        let msg = err.to_string();
2844        assert!(
2845            msg.contains("observe containers where"),
2846            "should show source"
2847        );
2848        assert!(msg.contains("^"), "should show pointer");
2849        // Verify arrow and source reference are present
2850        assert!(msg.contains("-->"), "should show arrow marker");
2851        assert!(msg.contains(" column "), "should mention column");
2852    }
2853
2854    #[test]
2855    fn error_pipe_pipeline_node_suggestion() {
2856        // Using | without a valid pipeline node keyword
2857        let err = parse("observe containers | unknown_node").unwrap_err();
2858        assert!(err.suggestion.is_some());
2859        let sug = err.suggestion.as_ref().unwrap();
2860        assert!(
2861            sug.contains("where"),
2862            "should suggest pipeline keywords: {sug}"
2863        );
2864    }
2865
2866    #[test]
2867    fn pipeline_keyword_similar_suggestion() {
2868        // "fltr" -> should suggest "fill" (starts with 'f')
2869        let err = parse("observe containers | fltr name = test").unwrap_err();
2870        let sug = err.suggestion.as_ref().expect("should have suggestion");
2871        assert!(
2872            sug.contains("fill"),
2873            "should suggest `fill` for `fltr`: {sug}"
2874        );
2875        assert!(
2876            sug.contains("did you mean"),
2877            "should contain 'did you mean': {sug}"
2878        );
2879    }
2880
2881    #[test]
2882    fn pipeline_keyword_grp_suggests_group() {
2883        // "grp" -> should suggest "group" (starts with 'g')
2884        let err = parse("observe containers | grp by image").unwrap_err();
2885        let sug = err.suggestion.as_ref().expect("should have suggestion");
2886        assert!(
2887            sug.contains("group"),
2888            "should suggest `group` for `grp`: {sug}"
2889        );
2890        assert!(
2891            sug.contains("did you mean"),
2892            "should contain 'did you mean': {sug}"
2893        );
2894    }
2895
2896    #[test]
2897    fn analysis_verb_similar_suggestion() {
2898        // "delet" -> should suggest "find" (no verb starts with 'd', falls back to generic)
2899        let err = parse("analyze containers delet").unwrap_err();
2900        let sug = err.suggestion.as_ref().expect("should have suggestion");
2901        // Generic suggestion should mention valid verbs
2902        assert!(
2903            sug.contains("find") || sug.contains("correlate") || sug.contains("explain"),
2904            "should list analysis verbs: {sug}"
2905        );
2906    }
2907
2908    #[test]
2909    fn alert_action_similar_suggestion() {
2910        // "emai" -> should suggest "print" (starts with 'p' is close; 'emai' starts with 'e' -> no match, generic)
2911        // Actually "emai" starts with 'e' and no action starts with 'e', so generic message
2912        let err = parse(r#"alert when cpu > 80% then emai "alert""#).unwrap_err();
2913        let sug = err.suggestion.as_ref().expect("should have suggestion");
2914        assert!(
2915            sug.contains("print") || sug.contains("webhook") || sug.contains("restart"),
2916            "should list alert actions: {sug}"
2917        );
2918    }
2919
2920    #[test]
2921    fn alert_action_close_match_suggestion() {
2922        // "prnt" -> should suggest "print" (starts with 'p')
2923        let err = parse(r#"alert when cpu > 80% then prnt "alert""#).unwrap_err();
2924        let sug = err.suggestion.as_ref().expect("should have suggestion");
2925        assert!(
2926            sug.contains("print"),
2927            "should suggest `print` for `prnt`: {sug}"
2928        );
2929        assert!(
2930            sug.contains("did you mean"),
2931            "should contain 'did you mean': {sug}"
2932        );
2933    }
2934
2935    #[test]
2936    fn suggest_keyword_function_direct() {
2937        // Direct tests for suggest_keyword helper
2938        assert!(
2939            suggest_keyword("", PIPELINE_KEYWORDS).is_none(),
2940            "empty input should return None"
2941        );
2942        assert!(
2943            suggest_keyword("xyz", PIPELINE_KEYWORDS).is_none(),
2944            "no match should return None"
2945        );
2946        let result = suggest_keyword("whre", PIPELINE_KEYWORDS).expect("should find suggestion");
2947        assert!(
2948            result.contains("where"),
2949            "should suggest `where` for `whre`: {result}"
2950        );
2951        let result = suggest_keyword("slect", PIPELINE_KEYWORDS).expect("should find suggestion");
2952        assert!(
2953            result.contains("select"),
2954            "should suggest `select` for `slect`: {result}"
2955        );
2956        let result = suggest_keyword("lmit", PIPELINE_KEYWORDS).expect("should find suggestion");
2957        assert!(
2958            result.contains("limit"),
2959            "should suggest `limit` for `lmit`: {result}"
2960        );
2961    }
2962
2963    #[test]
2964    fn suggest_keyword_analysis_verbs() {
2965        let result = suggest_keyword("fnd", ANALYSIS_VERBS).expect("should find suggestion");
2966        assert!(
2967            result.contains("find"),
2968            "should suggest `find` for `del`: {result}"
2969        );
2970        let result = suggest_keyword("corr", ANALYSIS_VERBS).expect("should find suggestion");
2971        assert!(
2972            result.contains("correlate"),
2973            "should suggest `correlate` for `corr`: {result}"
2974        );
2975        let result = suggest_keyword("expln", ANALYSIS_VERBS).expect("should find suggestion");
2976        assert!(
2977            result.contains("explain"),
2978            "should suggest `explain` for `expln`: {result}"
2979        );
2980    }
2981
2982    #[test]
2983    fn suggest_keyword_alert_actions() {
2984        let result = suggest_keyword("prnt", ALERT_ACTIONS).expect("should find suggestion");
2985        assert!(
2986            result.contains("print"),
2987            "should suggest `print` for `prnt`: {result}"
2988        );
2989        let result = suggest_keyword("webhk", ALERT_ACTIONS).expect("should find suggestion");
2990        assert!(
2991            result.contains("webhook"),
2992            "should suggest `webhook` for `webhk`: {result}"
2993        );
2994        let result = suggest_keyword("restt", ALERT_ACTIONS).expect("should find suggestion");
2995        assert!(
2996            result.contains("restart"),
2997            "should suggest `restart` for `restt`: {result}"
2998        );
2999    }
3000
3001    #[test]
3002    fn error_invalid_float_displays_message() {
3003        // Tokenizer produces "invalid float" for malformed numbers
3004        let err = parse("observe containers | where cpu > 1.2.3").unwrap_err();
3005        // The tokenizer will consume "1.2" as a float, then ".3" as unexpected
3006        // This should still produce a meaningful error
3007        let msg = err.to_string();
3008        assert!(!msg.is_empty(), "error should have a message");
3009    }
3010
3011    // ── suggest_similar_target and str_similarity tests ──
3012
3013    #[test]
3014    fn suggest_similar_target_exact_match_falls_through() {
3015        // suggest_similar_target is not called for valid inputs
3016        // but for invalid ones it should give sensible suggestions
3017        let sug = suggest_similar_target("container");
3018        // "container" is a valid singular target, but just testing the function
3019        assert!(sug.contains("containers") || sug.contains("try one of"));
3020    }
3021
3022    #[test]
3023    fn suggest_similar_target_empty_input() {
3024        let sug = suggest_similar_target("");
3025        assert!(
3026            sug.contains("try one of"),
3027            "empty input should show generic message: {sug}"
3028        );
3029    }
3030
3031    #[test]
3032    fn suggest_similar_target_completely_wrong() {
3033        let sug = suggest_similar_target("xyz");
3034        assert!(
3035            sug.contains("try one of"),
3036            "completely wrong input should show generic message: {sug}"
3037        );
3038    }
3039
3040    #[test]
3041    fn suggest_similar_target_close_match() {
3042        let sug = suggest_similar_target("imags");
3043        assert!(
3044            sug.contains("images"),
3045            "should suggest `images` for `imags`: {sug}"
3046        );
3047    }
3048
3049    #[test]
3050    fn str_similarity_identical() {
3051        assert_eq!(str_similarity("hello", "hello"), 0);
3052    }
3053
3054    #[test]
3055    fn str_similarity_prefix_match() {
3056        // "test" vs "testing": Levenshtein = 3 (insert 'i', 'n', 'g')
3057        assert_eq!(str_similarity("test", "testing"), 3);
3058    }
3059
3060    #[test]
3061    fn str_similarity_no_match() {
3062        // "abc" vs "xyz": prefix 0, len diff 0 => (3-0) + 0*2 = 3
3063        assert_eq!(str_similarity("abc", "xyz"), 3);
3064    }
3065
3066    // ── Builder chaining tests ──
3067
3068    #[test]
3069    fn parse_error_builder_chaining() {
3070        let err = ParseError::new(10, "test")
3071            .with_source("source query".to_owned())
3072            .with_suggestion("a suggestion");
3073        assert_eq!(err.column, 10);
3074        assert_eq!(err.message, "test");
3075        assert_eq!(err.source.as_deref(), Some("source query"));
3076        assert_eq!(err.suggestion.as_deref(), Some("a suggestion"));
3077    }
3078
3079    #[test]
3080    fn parse_error_without_suggestion_displays_no_help() {
3081        let err = ParseError::new(1, "simple error");
3082        assert!(err.suggestion.is_none());
3083        let msg = err.to_string();
3084        assert!(!msg.contains("help:"), "no suggestion should not show help");
3085    }
3086
3087    #[test]
3088    fn format_color_shows_suggestion() {
3089        let err = ParseError::new(5, "error")
3090            .with_source("test query".to_owned())
3091            .with_suggestion("try something");
3092        let colored = err.format_color();
3093        assert!(
3094            colored.contains("try something"),
3095            "should include suggestion text"
3096        );
3097        assert!(colored.contains('\x1b'), "should include ANSI codes");
3098    }
3099
3100    #[test]
3101    fn format_color_without_suggestion_still_has_error() {
3102        let err = ParseError::new(1, "simple error");
3103        let colored = err.format_color();
3104        assert!(
3105            colored.contains("error"),
3106            "format_color must include the word 'error': {colored}"
3107        );
3108        assert!(
3109            !colored.contains("help:"),
3110            "no suggestion should not show help"
3111        );
3112    }
3113
3114    #[test]
3115    fn source_context_shows_correct_line() {
3116        // Test with a multi-line query to verify line detection works
3117        let err = parse("observe containers\n| where cpu > 80%\n| unknown\n| limit 5").unwrap_err();
3118        let msg = err.to_string();
3119        // The error should point to the line with "unknown"
3120        assert!(
3121            msg.contains("| unknown"),
3122            "should show the line with the error: {msg}"
3123        );
3124        // Should not show the first line in the source pointer
3125        assert!(
3126            !msg.lines()
3127                .any(|l| l.contains("observe containers") && l.contains("-->")),
3128            "source pointer should point to the line with unknown, not the first line"
3129        );
3130    }
3131
3132    // ---- Window function parse tests ----
3133
3134    #[test]
3135    fn parses_row_number() {
3136        let q = parse_one("observe containers | row_number as rn");
3137        let Query::Observe(ref o) = q.query else {
3138            panic!("expected Observe")
3139        };
3140        match &o.pipeline[0] {
3141            PipelineNode::RowNumber { alias } => assert_eq!(alias, "rn"),
3142            other => panic!("expected RowNumber, got {other:?}"),
3143        }
3144    }
3145
3146    #[test]
3147    fn parses_rank() {
3148        let q = parse_one("observe containers | sort by cpu desc | rank(cpu) as r");
3149        let Query::Observe(ref o) = q.query else {
3150            panic!("expected Observe")
3151        };
3152        match &o.pipeline[1] {
3153            PipelineNode::Rank { field, alias } => {
3154                assert_eq!(field, "cpu");
3155                assert_eq!(alias, "r");
3156            }
3157            other => panic!("expected Rank, got {other:?}"),
3158        }
3159    }
3160
3161    #[test]
3162    fn parses_lag() {
3163        let q = parse_one("observe containers | lag(name) as prev_name");
3164        let Query::Observe(ref o) = q.query else {
3165            panic!("expected Observe")
3166        };
3167        match &o.pipeline[0] {
3168            PipelineNode::Lag {
3169                field,
3170                alias,
3171                offset,
3172            } => {
3173                assert_eq!(field, "name");
3174                assert_eq!(alias, "prev_name");
3175                assert_eq!(*offset, 1);
3176            }
3177            other => panic!("expected Lag, got {other:?}"),
3178        }
3179    }
3180
3181    #[test]
3182    fn parses_lead_with_offset() {
3183        let q = parse_one("observe containers | lead(name, 2) as next_name");
3184        let Query::Observe(ref o) = q.query else {
3185            panic!("expected Observe")
3186        };
3187        match &o.pipeline[0] {
3188            PipelineNode::Lead {
3189                field,
3190                alias,
3191                offset,
3192            } => {
3193                assert_eq!(field, "name");
3194                assert_eq!(alias, "next_name");
3195                assert_eq!(*offset, 2);
3196            }
3197            other => panic!("expected Lead, got {other:?}"),
3198        }
3199    }
3200}