Skip to main content

dol/
semantic.rs

1//! Semantic analysis and type checking.
2//!
3//! Validates DOL queries before execution: checks field existence,
4//! type compatibility in comparisons/arithmetic, function name validity,
5//! and pipeline node semantics. Uses a [`SemanticAnalyzer`] that tracks
6//! the active schema as pipeline nodes transform the data.
7//!
8//! # Example
9//!
10//! ```ignore
11//! semantic::validate_semantics(&query)?;
12//! ```
13
14use crate::ast::{CollectionTarget, Expression, PipelineNode, Query, SetValue, Value};
15use crate::eval::EvalError;
16use crate::target_alias;
17use std::collections::BTreeMap;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Type {
21    String,
22    Integer,
23    Float,
24    Boolean,
25    Percentage,
26    Duration,
27    Array,
28    Unknown,
29}
30
31impl Type {
32    #[must_use]
33    pub const fn is_numeric(&self) -> bool {
34        matches!(
35            self,
36            Self::Integer | Self::Float | Self::Percentage | Self::Duration
37        )
38    }
39
40    #[must_use]
41    pub fn is_compatible(&self, other: &Self) -> bool {
42        if *self == Self::Unknown || *other == Self::Unknown {
43            return true;
44        }
45        if self.is_numeric() && other.is_numeric() {
46            return true;
47        }
48        self == other
49    }
50}
51
52pub struct SemanticAnalyzer {
53    active_schema: BTreeMap<String, Type>,
54    target: CollectionTarget,
55}
56
57impl SemanticAnalyzer {
58    #[must_use]
59    pub fn new(target: CollectionTarget) -> Self {
60        let mut active_schema = BTreeMap::new();
61        for (field, ty) in Self::schema_for_target(target) {
62            active_schema.insert(field.to_owned(), ty);
63        }
64
65        Self {
66            active_schema,
67            target,
68        }
69    }
70
71    pub fn validate_query(&mut self, query: &Query) -> Result<(), EvalError> {
72        match query {
73            Query::Observe(q) => {
74                if let Some(join) = &q.join {
75                    let right_schema = Self::schema_for_target(join.right);
76                    let left_alias = target_alias(self.target);
77                    let right_alias = target_alias(join.right);
78                    let original_schema = std::mem::take(&mut self.active_schema);
79                    for (field, ty) in original_schema {
80                        self.active_schema
81                            .insert(format!("{left_alias}.{field}"), ty);
82                    }
83                    for (field, ty) in right_schema {
84                        self.active_schema
85                            .insert(format!("{right_alias}.{field}"), ty);
86                    }
87                }
88                if let Some(filter) = &q.filter {
89                    self.validate_expression(filter)?;
90                }
91                for node in &q.pipeline {
92                    self.apply_pipeline_node(node)?;
93                }
94            }
95            Query::Events(q) => {
96                if let Some(filter) = &q.filter {
97                    self.validate_expression(filter)?;
98                }
99                for node in &q.pipeline {
100                    self.apply_pipeline_node(node)?;
101                }
102            }
103            Query::Analyze(q) => {
104                for node in &q.pipeline {
105                    self.apply_pipeline_node(node)?;
106                }
107            }
108            Query::Alert(rule) => {
109                self.validate_expression(&rule.condition)?;
110            }
111            Query::Logs(q) => {
112                if let Some(filter) = &q.filter {
113                    self.validate_expression(filter)?;
114                }
115                for node in &q.pipeline {
116                    self.apply_pipeline_node(node)?;
117                }
118            }
119            Query::Compose(q) => {
120                match q.target {
121                    crate::ast::ComposeTarget::Services
122                    | crate::ast::ComposeTarget::Ps
123                    | crate::ast::ComposeTarget::Stats => {
124                        self.active_schema
125                            .insert("service".to_owned(), Type::String);
126                    }
127                    crate::ast::ComposeTarget::Health => {
128                        self.active_schema
129                            .insert("service".to_owned(), Type::String);
130                        if !self.active_schema.contains_key("health") {
131                            self.active_schema.insert("health".to_owned(), Type::String);
132                        }
133                    }
134                    crate::ast::ComposeTarget::Projects => {
135                        self.active_schema = BTreeMap::new();
136                        self.active_schema
137                            .insert("project".to_owned(), Type::String);
138                        self.active_schema
139                            .insert("containers".to_owned(), Type::Integer);
140                        self.active_schema
141                            .insert("running".to_owned(), Type::Integer);
142                        self.active_schema
143                            .insert("stopped".to_owned(), Type::Integer);
144                        self.active_schema
145                            .insert("networks".to_owned(), Type::Integer);
146                        self.active_schema
147                            .insert("volumes".to_owned(), Type::Integer);
148                        self.active_schema.insert("status".to_owned(), Type::String);
149                    }
150                    crate::ast::ComposeTarget::Images => {
151                        self.active_schema = BTreeMap::new();
152                        self.active_schema.insert("id".to_owned(), Type::String);
153                        self.active_schema
154                            .insert("repository".to_owned(), Type::String);
155                        self.active_schema.insert("name".to_owned(), Type::String);
156                        self.active_schema.insert("tag".to_owned(), Type::String);
157                        self.active_schema.insert("digest".to_owned(), Type::String);
158                        self.active_schema.insert("size".to_owned(), Type::String);
159                        self.active_schema
160                            .insert("service".to_owned(), Type::String);
161                    }
162                    crate::ast::ComposeTarget::Events => {
163                        self.active_schema = BTreeMap::new();
164                        self.active_schema.insert("time".to_owned(), Type::String);
165                        self.active_schema.insert("action".to_owned(), Type::String);
166                        self.active_schema
167                            .insert("service".to_owned(), Type::String);
168                        self.active_schema
169                            .insert("container".to_owned(), Type::String);
170                        self.active_schema.insert("image".to_owned(), Type::String);
171                    }
172                    crate::ast::ComposeTarget::Port => {
173                        self.active_schema = BTreeMap::new();
174                        self.active_schema
175                            .insert("service".to_owned(), Type::String);
176                        self.active_schema
177                            .insert("container".to_owned(), Type::String);
178                        self.active_schema.insert("ports".to_owned(), Type::Array);
179                    }
180                    crate::ast::ComposeTarget::Config => {
181                        self.active_schema = BTreeMap::new();
182                        self.active_schema.insert("name".to_owned(), Type::String);
183                        self.active_schema.insert("image".to_owned(), Type::String);
184                        self.active_schema.insert("state".to_owned(), Type::String);
185                        self.active_schema.insert("status".to_owned(), Type::String);
186                        self.active_schema.insert("ports".to_owned(), Type::Array);
187                        self.active_schema
188                            .insert("restart_count".to_owned(), Type::Integer);
189                        self.active_schema.insert("health".to_owned(), Type::String);
190                        self.active_schema
191                            .insert("depends_on".to_owned(), Type::Array);
192                        self.active_schema.insert("driver".to_owned(), Type::String);
193                        self.active_schema.insert("scope".to_owned(), Type::String);
194                        self.active_schema
195                            .insert("containers".to_owned(), Type::Integer);
196                        self.active_schema
197                            .insert("mountpoint".to_owned(), Type::String);
198                    }
199                    crate::ast::ComposeTarget::Logs => {
200                        self.active_schema = BTreeMap::new();
201                        self.active_schema.insert("line".to_owned(), Type::Integer);
202                        self.active_schema
203                            .insert("message".to_owned(), Type::String);
204                        self.active_schema
205                            .insert("service".to_owned(), Type::String);
206                        self.active_schema
207                            .insert("container".to_owned(), Type::String);
208                    }
209                    _ => {}
210                }
211                for node in &q.pipeline {
212                    self.apply_pipeline_node(node)?;
213                }
214            }
215            Query::Ping | Query::Inspect(_) | Query::Fields(_) => {}
216        }
217        Ok(())
218    }
219
220    fn check_field_validity(&self, field: &str) -> Result<(), EvalError> {
221        if self.active_schema.contains_key(field) {
222            return Ok(());
223        }
224        if field.starts_prefix_label() {
225            // Label lookups require the labels field to exist in base schema
226            if self.active_schema.contains_key("labels") {
227                return Ok(());
228            }
229        }
230        Err(EvalError::UnsupportedField {
231            field: field.to_owned(),
232        })
233    }
234
235    fn infer_expr_type(&self, expr: &Expression) -> Result<Type, EvalError> {
236        match expr {
237            Expression::Field(f) => {
238                self.check_field_validity(f)?;
239                if f.starts_prefix_label() {
240                    Ok(Type::String)
241                } else {
242                    Ok(*self.active_schema.get(f).unwrap_or(&Type::Unknown))
243                }
244            }
245            Expression::Literal(v) => match v {
246                Value::String(_) | Value::Identifier(_) => Ok(Type::String),
247                Value::Integer(_) => Ok(Type::Integer),
248                Value::Float(_) => Ok(Type::Float),
249                Value::Percentage(_) => Ok(Type::Percentage),
250                Value::Boolean(_) => Ok(Type::Boolean),
251            },
252            Expression::Arithmetic { left, op, right } => {
253                let lt = self.infer_expr_type(left)?;
254                let rt = self.infer_expr_type(right)?;
255                if !lt.is_numeric() || !rt.is_numeric() {
256                    return Err(EvalError::Arithmetic(format!(
257                        "invalid arithmetic operator '{op:?}' on types {lt:?} and {rt:?}"
258                    )));
259                }
260                if lt == Type::Float || rt == Type::Float {
261                    Ok(Type::Float)
262                } else {
263                    Ok(Type::Integer)
264                }
265            }
266            Expression::Comparison {
267                left,
268                operator,
269                right,
270            } => {
271                let lt = self.infer_expr_type(left)?;
272                let rt = self.infer_expr_type(right)?;
273                if !lt.is_compatible(&rt) {
274                    let field_name = match &**left {
275                        Expression::Field(f) => f.clone(),
276                        _ => "expression".to_owned(),
277                    };
278                    return Err(EvalError::InvalidComparison {
279                        field: field_name,
280                        operator: format!("{operator:?}"),
281                    });
282                }
283                Ok(Type::Boolean)
284            }
285            Expression::In { expr, .. }
286            | Expression::IsNull(expr)
287            | Expression::IsNotNull(expr)
288            | Expression::Between { expr, .. } => {
289                self.infer_expr_type(expr)?;
290                Ok(Type::Boolean)
291            }
292            Expression::And(left, right) | Expression::Or(left, right) => {
293                let lt = self.infer_expr_type(left)?;
294                let rt = self.infer_expr_type(right)?;
295                if lt != Type::Boolean && lt != Type::Unknown {
296                    return Err(EvalError::InvalidComparison {
297                        field: "AND/OR left operand".to_owned(),
298                        operator: "logical".to_owned(),
299                    });
300                }
301                if rt != Type::Boolean && rt != Type::Unknown {
302                    return Err(EvalError::InvalidComparison {
303                        field: "AND/OR right operand".to_owned(),
304                        operator: "logical".to_owned(),
305                    });
306                }
307                Ok(Type::Boolean)
308            }
309            Expression::Not(expr) => {
310                let t = self.infer_expr_type(expr)?;
311                if t != Type::Boolean && t != Type::Unknown {
312                    return Err(EvalError::InvalidComparison {
313                        field: "NOT operand".to_owned(),
314                        operator: "logical".to_owned(),
315                    });
316                }
317                Ok(Type::Boolean)
318            }
319            Expression::FnCall { name, args } => {
320                // Check if function exists
321                match name.as_str() {
322                    "upper" | "lower" | "trim" | "length" | "concat" | "substring" | "coalesce"
323                    | "to_int" | "to_float" | "to_string" | "starts_with" | "ends_with"
324                    | "replace" | "reverse" | "repeat" | "split_part" | "position" | "now"
325                    | "date_format" | "date_diff" | "extract" => {
326                        for arg in args {
327                            self.infer_expr_type(arg)?;
328                        }
329                        match name.as_str() {
330                            "to_float" => Ok(Type::Float),
331                            "length" | "position" | "date_diff" | "extract" | "to_int" => {
332                                Ok(Type::Integer)
333                            }
334                            "starts_with" | "ends_with" => Ok(Type::Boolean),
335                            _ => Ok(Type::String),
336                        }
337                    }
338                    _ => Err(EvalError::UnknownFunction { name: name.clone() }),
339                }
340            }
341        }
342    }
343
344    fn validate_expression(&self, expr: &Expression) -> Result<(), EvalError> {
345        self.infer_expr_type(expr)?;
346        Ok(())
347    }
348
349    /// Infer the type produced by a SetValue expression.
350    fn validate_set_value_type(&self, value: &SetValue) -> Result<Type, EvalError> {
351        match value {
352            SetValue::Literal(v) => match v {
353                Value::String(_) | Value::Identifier(_) => Ok(Type::String),
354                Value::Integer(_) => Ok(Type::Integer),
355                Value::Float(_) => Ok(Type::Float),
356                Value::Percentage(_) => Ok(Type::Percentage),
357                Value::Boolean(_) => Ok(Type::Boolean),
358            },
359            SetValue::Expr(expr) => self.infer_expr_type(expr),
360            SetValue::Case {
361                when_clauses,
362                else_value,
363            } => {
364                for (cond, val) in when_clauses {
365                    self.validate_expression(cond)?;
366                    self.validate_expression(val)?;
367                }
368                if let Some(else_expr) = else_value {
369                    self.validate_expression(else_expr)?;
370                }
371                Ok(Type::Unknown)
372            }
373            SetValue::IfElse {
374                condition,
375                then_value,
376                else_value,
377            } => {
378                self.validate_expression(condition)?;
379                self.validate_expression(then_value)?;
380                if let Some(else_expr) = else_value {
381                    self.validate_expression(else_expr)?;
382                }
383                Ok(Type::Unknown)
384            }
385        }
386    }
387
388    fn apply_pipeline_node(&mut self, node: &PipelineNode) -> Result<(), EvalError> {
389        match node {
390            PipelineNode::Where(expr) | PipelineNode::Having(expr) => {
391                self.validate_expression(expr)?;
392            }
393            PipelineNode::Select(fields) => {
394                let mut new_schema = BTreeMap::new();
395                for f in fields {
396                    self.check_field_validity(f)?;
397                    let ty = if f.starts_prefix_label() {
398                        Type::String
399                    } else {
400                        *self.active_schema.get(f).unwrap_or(&Type::Unknown)
401                    };
402                    new_schema.insert(f.clone(), ty);
403                }
404                self.active_schema = new_schema;
405            }
406            PipelineNode::SortBy { fields } => {
407                for (f, _) in fields {
408                    self.check_field_validity(f)?;
409                }
410            }
411            PipelineNode::Limit(_)
412            | PipelineNode::Offset(_)
413            | PipelineNode::Distinct
414            | PipelineNode::Alert(_)
415            | PipelineNode::Debug
416            | PipelineNode::Assert(_) => {}
417            PipelineNode::GroupBy { fields, aggregates } => {
418                let mut new_schema = BTreeMap::new();
419                for f in fields {
420                    self.check_field_validity(f)?;
421                    let ty = *self.active_schema.get(f).unwrap_or(&Type::Unknown);
422                    new_schema.insert(f.clone(), ty);
423                }
424                for agg in aggregates {
425                    self.check_field_validity(&agg.field)?;
426                    let ty = match agg.function.as_str() {
427                        "count" => Type::Integer,
428                        "sum" | "avg" | "min" | "max" | "median" | "percentile" => Type::Float,
429                        _ => Type::Unknown,
430                    };
431                    new_schema.insert(agg.alias.clone(), ty);
432                }
433                self.active_schema = new_schema;
434            }
435            PipelineNode::If {
436                condition,
437                then_branch,
438                else_branch,
439            } => {
440                self.validate_expression(condition)?;
441                let mut then_analyzer = Self {
442                    active_schema: self.active_schema.clone(),
443                    target: self.target,
444                };
445                for node in then_branch {
446                    then_analyzer.apply_pipeline_node(node)?;
447                }
448                if let Some(else_b) = else_branch {
449                    let mut else_analyzer = Self {
450                        active_schema: self.active_schema.clone(),
451                        target: self.target,
452                    };
453                    for node in else_b {
454                        else_analyzer.apply_pipeline_node(node)?;
455                    }
456                }
457            }
458            PipelineNode::Set { field, value } => {
459                let ty = match value {
460                    SetValue::Literal(v) => match v {
461                        Value::String(_) | Value::Identifier(_) => Type::String,
462                        Value::Integer(_) => Type::Integer,
463                        Value::Float(_) => Type::Float,
464                        Value::Percentage(_) => Type::Percentage,
465                        Value::Boolean(_) => Type::Boolean,
466                    },
467                    SetValue::Expr(expr) => self.infer_expr_type(expr)?,
468                    SetValue::Case {
469                        when_clauses,
470                        else_value,
471                    } => {
472                        for (cond, val) in when_clauses {
473                            self.validate_expression(cond)?;
474                            self.validate_expression(val)?;
475                        }
476                        if let Some(else_expr) = else_value {
477                            self.validate_expression(else_expr)?;
478                        }
479                        Type::Unknown
480                    }
481                    SetValue::IfElse {
482                        condition,
483                        then_value,
484                        else_value,
485                    } => {
486                        self.validate_expression(condition)?;
487                        self.validate_expression(then_value)?;
488                        if let Some(else_expr) = else_value {
489                            self.validate_expression(else_expr)?;
490                        }
491                        Type::Unknown
492                    }
493                };
494                self.active_schema.insert(field.clone(), ty);
495            }
496            PipelineNode::Fill {
497                field,
498                default,
499                condition,
500            } => {
501                if let Some(cond) = condition {
502                    self.validate_expression(cond)?;
503                }
504                let ty = self.validate_set_value_type(default)?;
505                self.active_schema.insert(field.clone(), ty);
506            }
507            PipelineNode::RowNumber { alias } => {
508                self.active_schema.insert(alias.clone(), Type::Integer);
509            }
510            PipelineNode::Rank { field, alias } => {
511                self.check_field_validity(field)?;
512                self.active_schema.insert(alias.clone(), Type::Integer);
513            }
514            PipelineNode::Lag { field, alias, .. } | PipelineNode::Lead { field, alias, .. } => {
515                self.check_field_validity(field)?;
516                let ty = *self.active_schema.get(field).unwrap_or(&Type::Unknown);
517                self.active_schema.insert(alias.clone(), ty);
518            }
519            PipelineNode::Let { name, value } => {
520                let ty = self.infer_expr_type(value)?;
521                self.active_schema.insert(name.clone(), ty);
522            }
523        }
524        Ok(())
525    }
526}
527
528impl SemanticAnalyzer {
529    fn schema_for_target(target: CollectionTarget) -> Vec<(&'static str, Type)> {
530        match target {
531            CollectionTarget::Containers => vec![
532                ("id", Type::String),
533                ("name", Type::String),
534                ("image", Type::String),
535                ("status", Type::String),
536                ("state", Type::String),
537                ("ports", Type::Array),
538                ("labels", Type::Array),
539                ("compose_project", Type::String),
540                ("created_at", Type::String),
541                ("started_at", Type::String),
542                ("finished_at", Type::String),
543                ("restart_count", Type::Integer),
544                ("cpu", Type::Float),
545                ("memory", Type::Integer),
546                ("memory_limit", Type::Integer),
547                ("network_rx", Type::Integer),
548                ("network_tx", Type::Integer),
549                ("disk_read", Type::Integer),
550                ("disk_write", Type::Integer),
551                ("health", Type::String),
552            ],
553            CollectionTarget::Images => vec![
554                ("id", Type::String),
555                ("repository", Type::String),
556                ("name", Type::String),
557                ("tag", Type::String),
558                ("digest", Type::String),
559                ("size", Type::String),
560                ("created_at", Type::String),
561                ("labels", Type::Array),
562            ],
563            CollectionTarget::Networks => vec![
564                ("id", Type::String),
565                ("name", Type::String),
566                ("driver", Type::String),
567                ("scope", Type::String),
568                ("containers", Type::Array),
569                ("labels", Type::Array),
570            ],
571            CollectionTarget::Volumes => vec![
572                ("name", Type::String),
573                ("driver", Type::String),
574                ("mountpoint", Type::String),
575                ("scope", Type::String),
576                ("labels", Type::Array),
577            ],
578        }
579    }
580}
581
582trait StartsPrefixLabel {
583    fn starts_prefix_label(&self) -> bool;
584}
585
586impl StartsPrefixLabel for str {
587    fn starts_prefix_label(&self) -> bool {
588        self.starts_with("label.")
589    }
590}
591
592impl StartsPrefixLabel for String {
593    fn starts_prefix_label(&self) -> bool {
594        self.as_str().starts_prefix_label()
595    }
596}
597
598pub fn validate_semantics(query: &Query) -> Result<(), EvalError> {
599    let target = match query {
600        Query::Observe(q) => q.target,
601        Query::Events(q) => q.target,
602        Query::Analyze(q) => match &q.target {
603            crate::ast::AnalysisTarget::Collection(t) => *t,
604            crate::ast::AnalysisTarget::Singular(s) => match s.kind {
605                crate::ast::SingularTargetKind::Container => CollectionTarget::Containers,
606                crate::ast::SingularTargetKind::Image => CollectionTarget::Images,
607                crate::ast::SingularTargetKind::Network => CollectionTarget::Networks,
608                crate::ast::SingularTargetKind::Volume => CollectionTarget::Volumes,
609            },
610        },
611        Query::Alert(_) | Query::Logs(_) | Query::Ping => CollectionTarget::Containers,
612        Query::Compose(q) => match q.target {
613            crate::ast::ComposeTarget::Networks => CollectionTarget::Networks,
614            crate::ast::ComposeTarget::Volumes => CollectionTarget::Volumes,
615            _ => CollectionTarget::Containers,
616        },
617        Query::Inspect(_) | Query::Fields(_) => return Ok(()),
618    };
619
620    let mut analyzer = SemanticAnalyzer::new(target);
621    analyzer.validate_query(query)
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::parser;
628
629    #[test]
630    fn test_valid_query() {
631        let parsed =
632            parser::parse("observe containers | where cpu > 50% | select name, cpu").unwrap();
633        let res = validate_semantics(&parsed.query);
634        assert!(res.is_ok());
635    }
636
637    #[test]
638    fn test_invalid_field() {
639        let parsed = parser::parse("observe containers | where invalid_field = 1").unwrap();
640        let res = validate_semantics(&parsed.query);
641        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
642    }
643
644    #[test]
645    fn test_invalid_comparison() {
646        let parsed = parser::parse("observe containers | where state > 50").unwrap();
647        let res = validate_semantics(&parsed.query);
648        assert!(matches!(res, Err(EvalError::InvalidComparison { .. })));
649    }
650
651    #[test]
652    fn test_invalid_arithmetic() {
653        let parsed = parser::parse("observe containers | set val = state + 1").unwrap();
654        let res = validate_semantics(&parsed.query);
655        assert!(matches!(res, Err(EvalError::Arithmetic(_))));
656    }
657
658    #[test]
659    fn test_unknown_function() {
660        let parsed =
661            parser::parse("observe containers | where invalid_fn(name) = \"test\"").unwrap();
662        let res = validate_semantics(&parsed.query);
663        assert!(matches!(res, Err(EvalError::UnknownFunction { .. })));
664    }
665
666    #[test]
667    fn test_set_field_inheritance() {
668        let parsed =
669            parser::parse("observe containers | set tier = \"prod\" | select name, tier").unwrap();
670        let res = validate_semantics(&parsed.query);
671        assert!(res.is_ok());
672    }
673
674    #[test]
675    fn test_label_lookup() {
676        let parsed =
677            parser::parse("observe containers | where label.env = \"production\"").unwrap();
678        let res = validate_semantics(&parsed.query);
679        assert!(res.is_ok());
680    }
681
682    #[test]
683    fn test_nested_if_branch() {
684        let parsed = parser::parse(
685            "observe containers | if cpu > 90% then set high = true else set high = false",
686        )
687        .unwrap();
688        let res = validate_semantics(&parsed.query);
689        assert!(res.is_ok());
690    }
691
692    // ── Comprehensive if-branching tests ──
693
694    #[test]
695    fn test_if_without_else() {
696        let parsed =
697            parser::parse("observe containers | if cpu > 90% then alert \"high\"").unwrap();
698        let res = validate_semantics(&parsed.query);
699        assert!(res.is_ok());
700    }
701
702    #[test]
703    fn test_if_complex_condition() {
704        let parsed = parser::parse(
705            "observe containers | if cpu > 80% and memory > 500 then alert \"high\" else alert \"low\"",
706        )
707        .unwrap();
708        let res = validate_semantics(&parsed.query);
709        assert!(res.is_ok());
710    }
711
712    #[test]
713    fn test_if_function_condition() {
714        let parsed =
715            parser::parse("observe containers | if upper(name) = \"API\" then set found = true")
716                .unwrap();
717        let res = validate_semantics(&parsed.query);
718        assert!(res.is_ok());
719    }
720
721    #[test]
722    fn test_else_if_chain() {
723        let parsed = parser::parse(
724            "observe containers | if cpu > 90% then alert \"critical\" else if cpu > 70% then alert \"warning\" else alert \"ok\"",
725        )
726        .unwrap();
727        let res = validate_semantics(&parsed.query);
728        assert!(res.is_ok());
729    }
730
731    #[test]
732    fn test_if_with_select_in_branch() {
733        let parsed =
734            parser::parse("observe containers | if state = running then select name, state, cpu")
735                .unwrap();
736        let res = validate_semantics(&parsed.query);
737        assert!(res.is_ok());
738    }
739
740    #[test]
741    fn test_if_with_sort_limit_in_branch() {
742        let parsed = parser::parse(
743            "observe containers | if state = running then sort by cpu desc | limit 10",
744        )
745        .unwrap();
746        let res = validate_semantics(&parsed.query);
747        assert!(res.is_ok());
748    }
749
750    #[test]
751    fn test_if_with_distinct_in_branch() {
752        let parsed =
753            parser::parse("observe containers | if state = running then distinct").unwrap();
754        let res = validate_semantics(&parsed.query);
755        assert!(res.is_ok());
756    }
757
758    #[test]
759    fn test_multiple_if_nodes() {
760        let parsed = parser::parse(
761            "observe containers | if cpu > 90% then alert \"high\" | if memory > 500 then alert \"high_mem\"",
762        )
763        .unwrap();
764        let res = validate_semantics(&parsed.query);
765        assert!(res.is_ok());
766    }
767
768    #[test]
769    fn test_if_with_expr_set_in_branch() {
770        let parsed = parser::parse(
771            "observe containers | if state = running then set val = restart_count + 1 else set val = restart_count",
772        )
773        .unwrap();
774        let res = validate_semantics(&parsed.query);
775        assert!(res.is_ok());
776    }
777
778    #[test]
779    fn test_if_with_where_in_branch() {
780        let parsed = parser::parse(
781            "observe containers | if state = running then where cpu > 80% | select name, cpu",
782        )
783        .unwrap();
784        let res = validate_semantics(&parsed.query);
785        assert!(res.is_ok());
786    }
787
788    #[test]
789    fn test_nested_if_inside_then() {
790        let parsed = parser::parse(
791            "observe containers | if cpu > 90% then if memory > 500 then alert \"high\"",
792        )
793        .unwrap();
794        let res = validate_semantics(&parsed.query);
795        assert!(res.is_ok());
796    }
797
798    #[test]
799    fn test_nested_if_inside_else() {
800        let parsed = parser::parse(
801            "observe containers | if cpu > 90% then set critical = true else if memory > 500 then set high_mem = true",
802        )
803        .unwrap();
804        let res = validate_semantics(&parsed.query);
805        assert!(res.is_ok());
806    }
807
808    // ── Invalid if-branching tests ──
809
810    #[test]
811    fn test_if_invalid_field_in_condition() {
812        let parsed =
813            parser::parse("observe containers | if nonexistent > 50 then alert \"bad\"").unwrap();
814        let res = validate_semantics(&parsed.query);
815        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
816    }
817
818    #[test]
819    fn test_if_invalid_comparison_in_condition() {
820        let parsed =
821            parser::parse("observe containers | if state > 50 then alert \"bad\"").unwrap();
822        let res = validate_semantics(&parsed.query);
823        assert!(matches!(res, Err(EvalError::InvalidComparison { .. })));
824    }
825
826    #[test]
827    fn test_if_set_then_select_in_same_branch() {
828        // set + select within the same then branch should work
829        let parsed = parser::parse(
830            "observe containers | if cpu > 90% then set tier = \"high\" | select name, tier",
831        )
832        .unwrap();
833        let res = validate_semantics(&parsed.query);
834        assert!(res.is_ok());
835    }
836
837    #[test]
838    fn test_if_select_nonexistent_in_branch() {
839        // select with nonexistent field inside a branch should fail
840        let parsed =
841            parser::parse("observe containers | if cpu > 90% then select name, nonexistent")
842                .unwrap();
843        let res = validate_semantics(&parsed.query);
844        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
845    }
846
847    #[test]
848    fn test_if_invalid_field_in_branch() {
849        // Invalid field in where inside then branch
850        let parsed =
851            parser::parse("observe containers | if cpu > 90% then where nonexistent = 1").unwrap();
852        let res = validate_semantics(&parsed.query);
853        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
854    }
855
856    #[test]
857    fn test_if_invalid_arithmetic_in_branch_set() {
858        // Invalid arithmetic inside then branch (string + integer)
859        let parsed =
860            parser::parse("observe containers | if cpu > 90% then set val = state + 1").unwrap();
861        let res = validate_semantics(&parsed.query);
862        assert!(matches!(res, Err(EvalError::Arithmetic(_))));
863    }
864
865    #[test]
866    fn test_if_unknown_function_in_branch_where() {
867        // Unknown function inside else branch
868        let parsed = parser::parse(
869            "observe containers | if cpu > 90% then alert \"high\" else where bogus_fn(name) = \"x\"",
870        )
871        .unwrap();
872        let res = validate_semantics(&parsed.query);
873        assert!(matches!(res, Err(EvalError::UnknownFunction { .. })));
874    }
875
876    #[test]
877    fn test_invalid_and_or_types() {
878        let parsed = parser::parse("observe containers | where state and name").unwrap();
879        let res = validate_semantics(&parsed.query);
880        assert!(res.is_err());
881    }
882
883    #[test]
884    fn test_distinct_and_limit() {
885        let parsed = parser::parse("observe containers | distinct | limit 5").unwrap();
886        let res = validate_semantics(&parsed.query);
887        assert!(res.is_ok());
888    }
889
890    #[test]
891    fn test_group_by_fields() {
892        let parsed =
893            parser::parse("observe containers | group by state with count(id) as cnt").unwrap();
894        let res = validate_semantics(&parsed.query);
895        assert!(res.is_ok());
896    }
897
898    #[test]
899    fn test_other_collection_targets() {
900        let parsed = parser::parse("observe images | select name, size").unwrap();
901        let res = validate_semantics(&parsed.query);
902        assert!(res.is_ok());
903
904        let parsed = parser::parse("observe networks | select name, scope").unwrap();
905        let res = validate_semantics(&parsed.query);
906        assert!(res.is_ok());
907
908        let parsed = parser::parse("observe volumes | select name, scope").unwrap();
909        let res = validate_semantics(&parsed.query);
910        assert!(res.is_ok());
911    }
912
913    #[test]
914    fn test_compose_valid_query() {
915        let parsed = parser::parse("compose myapp | where cpu > 50% | select name, cpu").unwrap();
916        let res = validate_semantics(&parsed.query);
917        assert!(res.is_ok());
918    }
919
920    #[test]
921    fn test_compose_invalid_field() {
922        let parsed = parser::parse("compose myapp | where nonexistent_field = 1").unwrap();
923        let res = validate_semantics(&parsed.query);
924        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
925    }
926
927    #[test]
928    fn test_compose_services_service_field_available() {
929        let parsed = parser::parse("compose myapp services | select name, service").unwrap();
930        let res = validate_semantics(&parsed.query);
931        assert!(res.is_ok());
932    }
933
934    #[test]
935    fn test_compose_services_where_service() {
936        let parsed = parser::parse("compose myapp services | where service = \"api\"").unwrap();
937        let res = validate_semantics(&parsed.query);
938        assert!(res.is_ok());
939    }
940
941    #[test]
942    fn test_compose_set_and_select() {
943        let parsed =
944            parser::parse("compose myapp | set tier = \"prod\" | select name, tier").unwrap();
945        let res = validate_semantics(&parsed.query);
946        assert!(res.is_ok());
947    }
948
949    #[test]
950    fn test_compose_group_by() {
951        let parsed = parser::parse("compose myapp | group by state with count(id) as cnt").unwrap();
952        let res = validate_semantics(&parsed.query);
953        assert!(res.is_ok());
954    }
955
956    #[test]
957    fn test_compose_invalid_comparison() {
958        let parsed = parser::parse("compose myapp | where state > 50").unwrap();
959        let res = validate_semantics(&parsed.query);
960        assert!(matches!(res, Err(EvalError::InvalidComparison { .. })));
961    }
962
963    #[test]
964    fn test_compose_networks_valid_fields() {
965        let parsed = parser::parse("compose myapp networks | select name, driver").unwrap();
966        let res = validate_semantics(&parsed.query);
967        assert!(res.is_ok());
968    }
969
970    #[test]
971    fn test_compose_volumes_valid_fields() {
972        let parsed = parser::parse("compose myapp volumes | select name, driver").unwrap();
973        let res = validate_semantics(&parsed.query);
974        assert!(res.is_ok());
975    }
976
977    #[test]
978    fn test_compose_health_service_and_health_fields() {
979        let parsed = parser::parse("compose myapp health | select name, service, health").unwrap();
980        let res = validate_semantics(&parsed.query);
981        assert!(res.is_ok());
982    }
983
984    #[test]
985    fn test_compose_networks_invalid_field() {
986        let parsed = parser::parse("compose myapp networks | where cpu > 50").unwrap();
987        let res = validate_semantics(&parsed.query);
988        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
989    }
990
991    #[test]
992    fn test_compose_volumes_invalid_field() {
993        let parsed = parser::parse("compose myapp volumes | where state = \"running\"").unwrap();
994        let res = validate_semantics(&parsed.query);
995        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
996    }
997
998    #[test]
999    fn test_join_prefixed_fields_valid() {
1000        let parsed = parser::parse("observe containers join images on id = id | where c.image = \"nginx:latest\" | select c.name, i.name").unwrap();
1001        let res = validate_semantics(&parsed.query);
1002        assert!(res.is_ok());
1003    }
1004
1005    #[test]
1006    fn test_join_prefixed_field_rejects_non_prefixed() {
1007        let parsed =
1008            parser::parse("observe containers join images on id = id | where name = \"web\"")
1009                .unwrap();
1010        let res = validate_semantics(&parsed.query);
1011        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
1012    }
1013
1014    #[test]
1015    fn test_join_where_clause_invalid_field() {
1016        let parsed =
1017            parser::parse("observe containers join images on id = id | where nonexistent = \"x\"")
1018                .unwrap();
1019        let res = validate_semantics(&parsed.query);
1020        assert!(matches!(res, Err(EvalError::UnsupportedField { .. })));
1021    }
1022
1023    #[test]
1024    fn test_join_no_pipeline() {
1025        let parsed = parser::parse("observe containers join images on id = id").unwrap();
1026        let res = validate_semantics(&parsed.query);
1027        assert!(res.is_ok());
1028    }
1029}