Skip to main content

apif_ast/
assertion_ast.rs

1//! AST nodes for assertion expressions.
2//!
3//! Pipeline: `text → tokenize_assertion() → Vec<Token> → parse_assertion() → AssertionExpr`
4//!
5//! The tokenizer (`super::tokenizer`) is the single source of tokens with exact byte positions.
6//!
7//! Precedence (low to high):
8//!   pipe (`| not`)
9//!   or
10//!   xor
11//!   and
12//!   binary (==, !=, >, <, contains, matches, …)
13//!   unary (!, not, not not, !!)
14//!   atom (literal, @plugin, .path, paren)
15
16use serde::{Deserialize, Serialize};
17
18use crate::tokenizer::{TokenKind, tokenize_assertion};
19
20/// A complete assertion expression (top-level).
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub enum AssertionExpr {
23    Binary {
24        op: BinaryOp,
25        left: Box<AssertionExpr>,
26        right: Box<AssertionExpr>,
27    },
28    Not(Box<AssertionExpr>),
29    NotNot(Box<AssertionExpr>),
30    And {
31        left: Box<AssertionExpr>,
32        right: Box<AssertionExpr>,
33    },
34    Or {
35        left: Box<AssertionExpr>,
36        right: Box<AssertionExpr>,
37    },
38    Xor {
39        left: Box<AssertionExpr>,
40        right: Box<AssertionExpr>,
41    },
42    IfThenElse {
43        condition: Box<AssertionExpr>,
44        then_branch: Box<AssertionExpr>,
45        else_branch: Box<AssertionExpr>,
46    },
47    Paren(Box<AssertionExpr>),
48    Atom(Expr),
49    Raw(String),
50}
51
52/// Atomic expressions (leaf nodes).
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub enum Expr {
55    JqPath(String),
56    PluginCall {
57        name: String,
58        args: Vec<AssertionExpr>,
59    },
60    Literal(Literal),
61    Variable(String),
62    RegExp {
63        pattern: String,
64        flags: String,
65    },
66    Json(String),
67    Yaml(String),
68    /// Type annotation: `expr:TypeName`. Evaluates to `expr` at runtime,
69    /// but hints to the type checker that the expression has the given type.
70    As(Box<Expr>, String),
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74pub enum Literal {
75    Bool(bool),
76    Number(String),
77    Str(String),
78    Null,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub enum BinaryOp {
83    Eq,
84    Ne,
85    Gt,
86    Lt,
87    Ge,
88    Le,
89    Contains,
90    Matches,
91    StartsWith,
92    EndsWith,
93}
94
95impl BinaryOp {
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            Self::Eq => "==",
99            Self::Ne => "!=",
100            Self::Gt => ">",
101            Self::Lt => "<",
102            Self::Ge => ">=",
103            Self::Le => "<=",
104            Self::Contains => "contains",
105            Self::Matches => "matches",
106            Self::StartsWith => "startsWith",
107            Self::EndsWith => "endsWith",
108        }
109    }
110    fn try_parse(s: &str) -> Option<Self> {
111        match s {
112            "==" => Some(Self::Eq),
113            "!=" => Some(Self::Ne),
114            ">" => Some(Self::Gt),
115            "<" => Some(Self::Lt),
116            ">=" => Some(Self::Ge),
117            "<=" => Some(Self::Le),
118            "contains" => Some(Self::Contains),
119            "matches" => Some(Self::Matches),
120            "startsWith" | "startswith" => Some(Self::StartsWith),
121            "endsWith" | "endswith" => Some(Self::EndsWith),
122            _ => None,
123        }
124    }
125}
126
127// ─── Display ───────────────────────────────────────────────────────────
128
129impl std::fmt::Display for Expr {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            Self::JqPath(p) => write!(f, "{}", p),
133            Self::PluginCall { name, args } => {
134                write!(f, "@{}(", name)?;
135                for (i, a) in args.iter().enumerate() {
136                    if i > 0 {
137                        write!(f, ", ")?;
138                    }
139                    write!(f, "{}", a)?;
140                }
141                write!(f, ")")
142            }
143            Self::Literal(Literal::Bool(b)) => write!(f, "{}", b),
144            Self::Literal(Literal::Number(n)) => write!(f, "{}", n),
145            Self::Literal(Literal::Str(s)) => write!(f, "\"{}\"", s),
146            Self::Literal(Literal::Null) => write!(f, "null"),
147            Self::RegExp { pattern, flags } => {
148                write!(f, "/{}/", pattern)?;
149                if !flags.is_empty() {
150                    write!(f, "{}", flags)?;
151                }
152                Ok(())
153            }
154            Self::Json(s) => write!(f, "{}", s),
155            Self::Yaml(s) => write!(f, "{}", s),
156            Self::Variable(n) => write!(f, "${}", n),
157            Self::As(inner, type_name) => write!(f, "{}:{}", inner, type_name),
158        }
159    }
160}
161
162impl std::fmt::Display for AssertionExpr {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        fmt_assertion(self, f, 0)
165    }
166}
167
168fn fmt_assertion(e: &AssertionExpr, f: &mut std::fmt::Formatter<'_>, prec: u8) -> std::fmt::Result {
169    match e {
170        AssertionExpr::Or { left, right } => {
171            if prec > 1 {
172                write!(f, "(")?;
173            }
174            fmt_assertion(left, f, 1)?;
175            write!(f, " or ")?;
176            fmt_assertion(right, f, 1)?;
177            if prec > 1 {
178                write!(f, ")")?;
179            }
180            Ok(())
181        }
182        AssertionExpr::Xor { left, right } => {
183            if prec > 1 {
184                write!(f, "(")?;
185            }
186            fmt_assertion(left, f, 1)?;
187            write!(f, " xor ")?;
188            fmt_assertion(right, f, 1)?;
189            if prec > 1 {
190                write!(f, ")")?;
191            }
192            Ok(())
193        }
194        AssertionExpr::And { left, right } => {
195            if prec > 2 {
196                write!(f, "(")?;
197            }
198            fmt_assertion(left, f, 2)?;
199            write!(f, " and ")?;
200            fmt_assertion(right, f, 2)?;
201            if prec > 2 {
202                write!(f, ")")?;
203            }
204            Ok(())
205        }
206        AssertionExpr::Binary { op, left, right } => {
207            if prec > 3 {
208                write!(f, "(")?;
209            }
210            fmt_assertion(left, f, 3)?;
211            write!(f, " {} ", op.as_str())?;
212            fmt_assertion(right, f, 3)?;
213            if prec > 3 {
214                write!(f, ")")?;
215            }
216            Ok(())
217        }
218        AssertionExpr::Not(inner) => {
219            write!(f, "!")?;
220            fmt_assertion(inner, f, 4)
221        }
222        AssertionExpr::NotNot(inner) => {
223            write!(f, "not not ")?;
224            fmt_assertion(inner, f, 4)
225        }
226        AssertionExpr::IfThenElse {
227            condition,
228            then_branch,
229            else_branch,
230        } => {
231            write!(f, "(")?;
232            fmt_assertion(condition, f, 0)?;
233            write!(f, " ? ")?;
234            fmt_assertion(then_branch, f, 0)?;
235            write!(f, " : ")?;
236            fmt_assertion(else_branch, f, 0)?;
237            write!(f, ")")
238        }
239        AssertionExpr::Paren(inner) => {
240            write!(f, "(")?;
241            fmt_assertion(inner, f, 0)?;
242            write!(f, ")")
243        }
244        AssertionExpr::Atom(e) => write!(f, "{}", e),
245        AssertionExpr::Raw(s) => write!(f, "{}", s),
246    }
247}
248
249// ─── Parser ────────────────────────────────────────────────────────────
250
251/// Parse a raw assertion string into an AST.
252/// Falls back to `Raw` if parsing fails.
253pub fn parse_assertion(raw: &str) -> AssertionExpr {
254    let tokens = tokenize_assertion(raw);
255    if tokens.is_empty() {
256        return AssertionExpr::Raw(raw.to_string());
257    }
258    let mut pos = 0;
259    let expr = parse_pipe(&tokens, &mut pos);
260    if pos >= tokens.len() {
261        expr
262    } else {
263        AssertionExpr::Raw(raw.to_string())
264    }
265}
266
267fn parse_pipe(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
268    let mut expr = parse_or(ts, p);
269    while *p < ts.len() {
270        if !matches!(ts[*p].kind, TokenKind::Pipe) {
271            break;
272        }
273        *p += 1;
274        if *p < ts.len() && is_keyword(ts, *p, "not") {
275            *p += 1;
276            if *p < ts.len() && is_keyword(ts, *p, "not") {
277                *p += 1;
278            } else {
279                expr = AssertionExpr::Not(Box::new(expr));
280            }
281        } else {
282            break;
283        }
284    }
285    expr
286}
287
288fn parse_or(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
289    let mut left = parse_xor(ts, p);
290    while *p < ts.len() && is_keyword(ts, *p, "or") {
291        *p += 1;
292        let right = parse_xor(ts, p);
293        left = AssertionExpr::Or {
294            left: Box::new(left),
295            right: Box::new(right),
296        };
297    }
298    left
299}
300
301fn parse_xor(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
302    let mut left = parse_and(ts, p);
303    while *p < ts.len() && is_keyword(ts, *p, "xor") {
304        *p += 1;
305        let right = parse_and(ts, p);
306        left = AssertionExpr::Xor {
307            left: Box::new(left),
308            right: Box::new(right),
309        };
310    }
311    left
312}
313
314fn parse_and(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
315    let mut left = parse_bin(ts, p);
316    while *p < ts.len() && is_keyword(ts, *p, "and") {
317        *p += 1;
318        let right = parse_bin(ts, p);
319        left = AssertionExpr::And {
320            left: Box::new(left),
321            right: Box::new(right),
322        };
323    }
324    left
325}
326
327fn parse_bin(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
328    let mut left = parse_unary(ts, p);
329    loop {
330        let op = match ts.get(*p).map(|t| &t.kind) {
331            Some(TokenKind::Op(s)) => match s.as_str() {
332                "==" => BinaryOp::Eq,
333                "!=" => BinaryOp::Ne,
334                ">" => BinaryOp::Gt,
335                "<" => BinaryOp::Lt,
336                ">=" => BinaryOp::Ge,
337                "<=" => BinaryOp::Le,
338                "contains" => BinaryOp::Contains,
339                "matches" => BinaryOp::Matches,
340                "startsWith" | "startswith" => BinaryOp::StartsWith,
341                "endsWith" | "endswith" => BinaryOp::EndsWith,
342                _ => BinaryOp::EndsWith,
343            },
344            Some(TokenKind::Ident(s)) if is_bin_op_keyword(s) => {
345                BinaryOp::try_parse(s).unwrap_or(BinaryOp::EndsWith)
346            }
347            None => break,
348            _ => break,
349        };
350
351        *p += 1;
352        let right = parse_unary(ts, p);
353        left = AssertionExpr::Binary {
354            op,
355            left: Box::new(left),
356            right: Box::new(right),
357        };
358    }
359    left
360}
361
362fn parse_unary(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
363    if *p >= ts.len() {
364        return AssertionExpr::Raw(String::new());
365    }
366    match &ts[*p].kind {
367        TokenKind::Bang => {
368            *p += 1;
369            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Bang) {
370                *p += 1;
371                let inner = parse_unary(ts, p);
372                AssertionExpr::NotNot(Box::new(inner))
373            } else {
374                let inner = parse_unary(ts, p);
375                AssertionExpr::Not(Box::new(inner))
376            }
377        }
378        TokenKind::Ident(s) if s == "not" => {
379            *p += 1;
380            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Ident(ref s2) if s2 == "not") {
381                *p += 1;
382                let inner = parse_unary(ts, p);
383                AssertionExpr::NotNot(Box::new(inner))
384            } else {
385                let inner = parse_unary(ts, p);
386                AssertionExpr::Not(Box::new(inner))
387            }
388        }
389        TokenKind::Ident(s) if s == "if" => parse_if(ts, p),
390        TokenKind::LParen => {
391            *p += 1;
392            let inner = parse_pipe(ts, p);
393            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::RParen) {
394                *p += 1;
395            }
396            AssertionExpr::Paren(Box::new(inner))
397        }
398        _ => parse_atom(ts, p),
399    }
400}
401
402fn parse_if(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
403    *p += 1;
404    let cond = parse_pipe(ts, p);
405    if *p >= ts.len() || !is_keyword(ts, *p, "then") {
406        return AssertionExpr::Raw("if..then missing".into());
407    }
408    *p += 1;
409    let then_b = parse_pipe(ts, p);
410    if *p >= ts.len() || !is_keyword(ts, *p, "else") {
411        return AssertionExpr::Raw("if..else missing".into());
412    }
413    *p += 1;
414    let else_b = parse_pipe(ts, p);
415    if *p < ts.len() && is_keyword(ts, *p, "end") {
416        *p += 1;
417    }
418    AssertionExpr::IfThenElse {
419        condition: Box::new(cond),
420        then_branch: Box::new(then_b),
421        else_branch: Box::new(else_b),
422    }
423}
424
425fn parse_atom(ts: &[crate::tokenizer::Token], p: &mut usize) -> AssertionExpr {
426    if *p >= ts.len() {
427        return AssertionExpr::Atom(Expr::JqPath(String::new()));
428    }
429    let mut expr = match &ts[*p].kind {
430        TokenKind::StringLit(s) => {
431            *p += 1;
432            Expr::Literal(Literal::Str(s.clone()))
433        }
434        TokenKind::NumberLit(n) => {
435            *p += 1;
436            Expr::Literal(Literal::Number(n.clone()))
437        }
438        TokenKind::Ident(s) if s == "true" => {
439            *p += 1;
440            Expr::Literal(Literal::Bool(true))
441        }
442        TokenKind::Ident(s) if s == "false" => {
443            *p += 1;
444            Expr::Literal(Literal::Bool(false))
445        }
446        TokenKind::Ident(s) if s == "null" => {
447            *p += 1;
448            Expr::Literal(Literal::Null)
449        }
450        TokenKind::VarDelim => {
451            // {{var}} in assertions is deprecated — use $var instead.
452            // Skip over the tokens to let the expression fall to Raw.
453            *p += 1;
454            while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::VarDelim) {
455                *p += 1;
456            }
457            if *p < ts.len() {
458                *p += 1;
459            }
460            Expr::JqPath(String::new())
461        }
462        TokenKind::At => {
463            *p += 1;
464            let name = if *p < ts.len() {
465                if let TokenKind::Ident(s) = &ts[*p].kind {
466                    *p += 1;
467                    let mut name = s.clone();
468                    // @type.method syntax
469                    if *p + 1 < ts.len()
470                        && matches!(&ts[*p].kind, TokenKind::Dot)
471                        && matches!(&ts[*p + 1].kind, TokenKind::Ident(_))
472                    {
473                        *p += 1; // consume dot
474                        if let TokenKind::Ident(method) = &ts[*p].kind {
475                            name.push('.');
476                            name.push_str(method);
477                            *p += 1; // consume method name
478                        }
479                    }
480                    name
481                } else {
482                    String::new()
483                }
484            } else {
485                String::new()
486            };
487            let args = if *p < ts.len() && matches!(ts[*p].kind, TokenKind::LParen) {
488                *p += 1;
489                let mut args = Vec::with_capacity(4);
490                while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RParen) {
491                    let arg = parse_pipe(ts, p);
492                    args.push(arg);
493                    if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Comma) {
494                        *p += 1;
495                    }
496                }
497                if *p < ts.len() {
498                    *p += 1;
499                }
500                merge_hyphenated_args(args)
501            } else {
502                Vec::new()
503            };
504            Expr::PluginCall { name, args }
505        }
506        TokenKind::Op(op) if op == "-" => {
507            *p += 1;
508            if *p < ts.len()
509                && let TokenKind::NumberLit(n) = &ts[*p].kind
510            {
511                let neg = format!("-{}", n);
512                *p += 1;
513                Expr::Literal(Literal::Number(neg))
514            } else {
515                Expr::JqPath("-".to_string())
516            }
517        }
518        TokenKind::RegExpLit { pattern, flags } => {
519            *p += 1;
520            Expr::RegExp {
521                pattern: pattern.clone(),
522                flags: flags.clone(),
523            }
524        }
525        TokenKind::LBrace => {
526            *p += 1;
527            let mut json = String::with_capacity(64);
528            json.push('{');
529            let mut depth = 1;
530            while *p < ts.len() && depth > 0 {
531                match &ts[*p].kind {
532                    TokenKind::LBrace => {
533                        depth += 1;
534                        json.push('{');
535                        *p += 1;
536                    }
537                    TokenKind::RBrace => {
538                        depth -= 1;
539                        if depth > 0 {
540                            json.push('}');
541                        }
542                        *p += 1;
543                    }
544                    TokenKind::LParen => {
545                        json.push('(');
546                        *p += 1;
547                    }
548                    TokenKind::RParen => {
549                        json.push(')');
550                        *p += 1;
551                    }
552                    TokenKind::LBracket => {
553                        json.push('[');
554                        *p += 1;
555                    }
556                    TokenKind::RBracket => {
557                        json.push(']');
558                        *p += 1;
559                    }
560                    TokenKind::StringLit(s) => {
561                        json.push('"');
562                        json.push_str(s);
563                        json.push('"');
564                        *p += 1;
565                    }
566                    TokenKind::NumberLit(n) => {
567                        json.push_str(n);
568                        *p += 1;
569                    }
570                    TokenKind::Ident(s) => {
571                        json.push_str(s);
572                        *p += 1;
573                    }
574                    TokenKind::Comma => {
575                        json.push(',');
576                        *p += 1;
577                    }
578                    TokenKind::Op(s) => {
579                        json.push_str(s);
580                        *p += 1;
581                    }
582                    TokenKind::Dot => {
583                        json.push('.');
584                        *p += 1;
585                    }
586                    TokenKind::At => {
587                        json.push('@');
588                        *p += 1;
589                    }
590                    TokenKind::Colon => {
591                        json.push(':');
592                        *p += 1;
593                    }
594                    TokenKind::VarDelim => {
595                        json.push_str("{{ ");
596                        *p += 1;
597                        while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::VarDelim) {
598                            if let TokenKind::Ident(s) = &ts[*p].kind {
599                                json.push_str(s);
600                            }
601                            *p += 1;
602                        }
603                        if *p < ts.len() {
604                            json.push_str(" }}");
605                            *p += 1;
606                        } else {
607                            json.push_str(" }}");
608                        }
609                    }
610                    _ => {
611                        *p += 1;
612                    }
613                }
614            }
615            json.push('}');
616            Expr::Json(json)
617        }
618        TokenKind::Ident(s) if s.starts_with('$') => {
619            *p += 1;
620            Expr::Variable(s[1..].to_string())
621        }
622        TokenKind::Dot | TokenKind::Ident(_) => {
623            let mut path = String::with_capacity(24);
624            while *p < ts.len() {
625                if let TokenKind::Ident(s) = &ts[*p].kind
626                    && (is_bin_op_keyword(s) || is_keyword_token(&ts[*p].kind))
627                {
628                    break;
629                }
630                match &ts[*p].kind {
631                    TokenKind::Dot => {
632                        path.push('.');
633                        *p += 1;
634                    }
635                    TokenKind::Ident(s) => {
636                        path.push_str(s);
637                        *p += 1;
638                    }
639                    TokenKind::StringLit(s) => {
640                        path.push('.');
641                        path.push('"');
642                        path.push_str(s);
643                        path.push('"');
644                        *p += 1;
645                    }
646                    TokenKind::Op(op) if op == "-" || op == ":" => {
647                        path.push_str(op);
648                        *p += 1;
649                    }
650                    TokenKind::LBracket => {
651                        path.push('[');
652                        *p += 1;
653                        while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RBracket) {
654                            if let TokenKind::NumberLit(n) = &ts[*p].kind {
655                                path.push_str(n);
656                            } else if let TokenKind::Ident(s) = &ts[*p].kind {
657                                path.push_str(s);
658                            } else if let TokenKind::StringLit(s) = &ts[*p].kind {
659                                path.push('"');
660                                path.push_str(s);
661                                path.push('"');
662                            } else if let TokenKind::Op(op) = &ts[*p].kind
663                                && (op == ":" || op == "-")
664                            {
665                                path.push_str(op);
666                            } else if let TokenKind::Dot = &ts[*p].kind {
667                                path.push('.');
668                            }
669                            *p += 1;
670                        }
671                        if *p < ts.len() {
672                            path.push(']');
673                            *p += 1;
674                        }
675                    }
676                    _ => break,
677                }
678            }
679            Expr::JqPath(path)
680        }
681        _ => {
682            *p += 1;
683            Expr::JqPath(String::new())
684        }
685    };
686
687    // Parse optional `:TypeName` type annotation
688    if *p < ts.len() && matches!(&ts[*p].kind, TokenKind::Colon) {
689        *p += 1; // consume ':'
690        if let TokenKind::Ident(type_name) = &ts[*p].kind {
691            let name = type_name.clone();
692            *p += 1; // consume type name
693            expr = Expr::As(Box::new(expr), name);
694        }
695    }
696
697    AssertionExpr::Atom(expr)
698}
699
700fn is_keyword(ts: &[crate::tokenizer::Token], idx: usize, kw: &str) -> bool {
701    matches!(ts.get(idx), Some(t) if matches!(&t.kind, TokenKind::Ident(s) if s == kw))
702}
703
704/// Merge consecutive bare JqPath args that were split by `-`.
705/// e.g. `[JqPath("content"), JqPath("type")]` → `[JqPath("content-type")]`
706fn merge_hyphenated_args(args: Vec<AssertionExpr>) -> Vec<AssertionExpr> {
707    if args.len() <= 1 {
708        return args;
709    }
710
711    let mut merged = Vec::with_capacity(args.len());
712
713    let mut iter = args.into_iter().peekable();
714    while let Some(current) = iter.next() {
715        match current {
716            AssertionExpr::Atom(Expr::JqPath(mut cur)) if !cur.contains('.') => {
717                while let Some(AssertionExpr::Atom(Expr::JqPath(nxt))) = iter.peek() {
718                    if nxt.contains('.') {
719                        break;
720                    }
721
722                    let Some(AssertionExpr::Atom(Expr::JqPath(nxt_owned))) = iter.next() else {
723                        break;
724                    };
725
726                    cur.push('-');
727                    cur.push_str(&nxt_owned);
728                }
729
730                merged.push(AssertionExpr::Atom(Expr::JqPath(cur)));
731            }
732            other => merged.push(other),
733        }
734    }
735
736    merged
737}
738
739fn is_bin_op_keyword(s: &str) -> bool {
740    matches!(
741        s,
742        "contains" | "matches" | "startsWith" | "endsWith" | "startswith" | "endswith"
743    )
744}
745
746fn is_keyword_token(k: &TokenKind) -> bool {
747    matches!(
748        k,
749        TokenKind::Ident(s)
750            if matches!(
751                s.as_str(),
752                "and" | "or" | "xor" | "contains" | "matches" | "startsWith"
753                    | "endsWith" | "startswith" | "endswith"
754            )
755    )
756}
757
758// ─── Public helpers ─────────────────────────────────────────────────────
759
760/// Convert AssertionExpr back to string (ternary for if-then-else).
761pub fn assertion_to_string(expr: &AssertionExpr) -> String {
762    let mut out = String::with_capacity(64);
763    push_assertion(expr, &mut out, 0);
764    out
765}
766
767fn push_expr(expr: &Expr, out: &mut String) {
768    match expr {
769        Expr::JqPath(p) => out.push_str(p),
770        Expr::PluginCall { name, args } => {
771            out.push('@');
772            out.push_str(name);
773            out.push('(');
774            for (i, a) in args.iter().enumerate() {
775                if i > 0 {
776                    out.push_str(", ");
777                }
778                push_assertion(a, out, 0);
779            }
780            out.push(')');
781        }
782        Expr::Literal(Literal::Bool(b)) => out.push_str(if *b { "true" } else { "false" }),
783        Expr::Literal(Literal::Number(n)) => out.push_str(n),
784        Expr::Literal(Literal::Str(s)) => {
785            out.push('"');
786            out.push_str(s);
787            out.push('"');
788        }
789        Expr::Literal(Literal::Null) => out.push_str("null"),
790        Expr::Variable(n) => {
791            out.push('$');
792            out.push_str(n);
793        }
794        Expr::RegExp { pattern, flags } => {
795            out.push('/');
796            out.push_str(pattern);
797            out.push('/');
798            out.push_str(flags);
799        }
800        Expr::Json(s) | Expr::Yaml(s) => out.push_str(s),
801        Expr::As(inner, type_name) => {
802            push_expr(inner, out);
803            out.push(':');
804            out.push_str(type_name);
805        }
806    }
807}
808
809fn push_assertion(expr: &AssertionExpr, out: &mut String, prec: u8) {
810    match expr {
811        AssertionExpr::Or { left, right } => {
812            if prec > 1 {
813                out.push('(');
814            }
815            push_assertion(left, out, 1);
816            out.push_str(" or ");
817            push_assertion(right, out, 1);
818            if prec > 1 {
819                out.push(')');
820            }
821        }
822        AssertionExpr::Xor { left, right } => {
823            if prec > 1 {
824                out.push('(');
825            }
826            push_assertion(left, out, 1);
827            out.push_str(" xor ");
828            push_assertion(right, out, 1);
829            if prec > 1 {
830                out.push(')');
831            }
832        }
833        AssertionExpr::And { left, right } => {
834            if prec > 2 {
835                out.push('(');
836            }
837            push_assertion(left, out, 2);
838            out.push_str(" and ");
839            push_assertion(right, out, 2);
840            if prec > 2 {
841                out.push(')');
842            }
843        }
844        AssertionExpr::Binary { op, left, right } => {
845            if prec > 3 {
846                out.push('(');
847            }
848            push_assertion(left, out, 3);
849            out.push(' ');
850            out.push_str(op.as_str());
851            out.push(' ');
852            push_assertion(right, out, 3);
853            if prec > 3 {
854                out.push(')');
855            }
856        }
857        AssertionExpr::Not(inner) => {
858            out.push('!');
859            push_assertion(inner, out, 4);
860        }
861        AssertionExpr::NotNot(inner) => {
862            out.push_str("not not ");
863            push_assertion(inner, out, 4);
864        }
865        AssertionExpr::IfThenElse {
866            condition,
867            then_branch,
868            else_branch,
869        } => {
870            out.push('(');
871            push_assertion(condition, out, 0);
872            out.push_str(" ? ");
873            push_assertion(then_branch, out, 0);
874            out.push_str(" : ");
875            push_assertion(else_branch, out, 0);
876            out.push(')');
877        }
878        AssertionExpr::Paren(inner) => {
879            out.push('(');
880            push_assertion(inner, out, 0);
881            out.push(')');
882        }
883        AssertionExpr::Atom(e) => push_expr(e, out),
884        AssertionExpr::Raw(s) => out.push_str(s),
885    }
886}
887
888/// Remove redundant parentheses.
889pub fn remove_redundant_parens(expr: &AssertionExpr) -> AssertionExpr {
890    match expr {
891        AssertionExpr::Paren(inner) => remove_redundant_parens(inner),
892        AssertionExpr::Binary { op, left, right } => AssertionExpr::Binary {
893            op: *op,
894            left: Box::new(remove_redundant_parens(left)),
895            right: Box::new(remove_redundant_parens(right)),
896        },
897        AssertionExpr::Not(e) => AssertionExpr::Not(Box::new(remove_redundant_parens(e))),
898        AssertionExpr::NotNot(e) => AssertionExpr::NotNot(Box::new(remove_redundant_parens(e))),
899        AssertionExpr::And { left, right } => AssertionExpr::And {
900            left: Box::new(remove_redundant_parens(left)),
901            right: Box::new(remove_redundant_parens(right)),
902        },
903        AssertionExpr::Or { left, right } => AssertionExpr::Or {
904            left: Box::new(remove_redundant_parens(left)),
905            right: Box::new(remove_redundant_parens(right)),
906        },
907        AssertionExpr::Xor { left, right } => AssertionExpr::Xor {
908            left: Box::new(remove_redundant_parens(left)),
909            right: Box::new(remove_redundant_parens(right)),
910        },
911        AssertionExpr::IfThenElse {
912            condition,
913            then_branch,
914            else_branch,
915        } => AssertionExpr::IfThenElse {
916            condition: Box::new(remove_redundant_parens(condition)),
917            then_branch: Box::new(remove_redundant_parens(then_branch)),
918            else_branch: Box::new(remove_redundant_parens(else_branch)),
919        },
920        _ => expr.clone(),
921    }
922}
923
924// ─── Tests ──────────────────────────────────────────────────────────────
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    #[cfg(not(miri))]
930    use std::hint::black_box;
931    #[cfg(not(miri))]
932    use std::time::Instant;
933
934    #[cfg(not(miri))]
935    fn bench_phase(name: &str, iterations: u32, mut f: impl FnMut()) {
936        let start = Instant::now();
937        for _ in 0..iterations {
938            f();
939        }
940        let elapsed = start.elapsed();
941        let per_call = elapsed / iterations;
942        eprintln!(
943            "{}: {} iterations in {:?} ({:?}/call)",
944            name, iterations, elapsed, per_call
945        );
946    }
947
948    #[test]
949    #[cfg(not(miri))]
950    fn perf_phase_breakdown_simple() {
951        let expr = ".id == 42 and .active == true";
952
953        bench_phase("ast_phase_simple_tokenize", 200_000, || {
954            let tokens = tokenize_assertion(black_box(expr));
955            black_box(tokens.len());
956        });
957
958        bench_phase("ast_phase_simple_parse_from_tokens", 200_000, || {
959            let tokens = tokenize_assertion(black_box(expr));
960            let mut pos = 0;
961            let ast = parse_pipe(&tokens, &mut pos);
962            black_box(matches!(ast, AssertionExpr::Raw(_)));
963            black_box(pos);
964        });
965
966        bench_phase("ast_phase_simple_serialize", 200_000, || {
967            let ast = parse_assertion(black_box(expr));
968            let s = assertion_to_string(&ast);
969            black_box(s.len());
970        });
971    }
972
973    #[test]
974    #[cfg(not(miri))]
975    fn perf_phase_breakdown_complex() {
976        let expr = "if @len(.items) > 0 then (@regex(.name, /foo.*/i) and .meta.version >= 2) else (.status == \"empty\" or {{ feature_flag }} == true) end";
977
978        bench_phase("ast_phase_complex_tokenize", 100_000, || {
979            let tokens = tokenize_assertion(black_box(expr));
980            black_box(tokens.len());
981        });
982
983        bench_phase("ast_phase_complex_parse_from_tokens", 100_000, || {
984            let tokens = tokenize_assertion(black_box(expr));
985            let mut pos = 0;
986            let ast = parse_pipe(&tokens, &mut pos);
987            black_box(matches!(ast, AssertionExpr::Raw(_)));
988            black_box(pos);
989        });
990
991        bench_phase("ast_phase_complex_serialize", 50_000, || {
992            let ast = parse_assertion(black_box(expr));
993            let s = assertion_to_string(&ast);
994            black_box(s.len());
995        });
996    }
997
998    #[test]
999    fn test_parse_simple_equality() {
1000        let expr = parse_assertion(".id == 123");
1001        if let AssertionExpr::Binary { op, left, right } = expr {
1002            assert_eq!(op, BinaryOp::Eq);
1003            assert!(matches!(*left, AssertionExpr::Atom(Expr::JqPath(_))));
1004            assert!(matches!(
1005                *right,
1006                AssertionExpr::Atom(Expr::Literal(Literal::Number(_)))
1007            ));
1008        } else {
1009            panic!("Expected Binary, got: {:?}", expr);
1010        }
1011    }
1012
1013    #[test]
1014    fn test_parse_plugin_call() {
1015        let expr = parse_assertion("@uuid(.user_id) == true");
1016        if let AssertionExpr::Binary { op, left, .. } = expr {
1017            assert_eq!(op, BinaryOp::Eq);
1018            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1019                assert_eq!(name, "uuid");
1020                assert_eq!(args.len(), 1);
1021            } else {
1022                panic!("Expected PluginCall");
1023            }
1024        } else {
1025            panic!("Expected Binary, got: {:?}", expr);
1026        }
1027    }
1028
1029    #[test]
1030    fn test_parse_negation_bang() {
1031        let expr = parse_assertion("!@has_header(\"x\")");
1032        if let AssertionExpr::Not(inner) = expr {
1033            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1034                assert_eq!(name, "has_header");
1035            } else {
1036                panic!("Expected PluginCall inside Not");
1037            }
1038        } else {
1039            panic!("Expected Not, got: {:?}", expr);
1040        }
1041    }
1042
1043    #[test]
1044    fn test_parse_pipe_not() {
1045        let expr = parse_assertion("@empty(.id) | not");
1046        if let AssertionExpr::Not(inner) = expr {
1047            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1048                assert_eq!(name, "empty");
1049            } else {
1050                panic!("Expected PluginCall inside Not, got: {:?}", inner);
1051            }
1052        } else {
1053            panic!("Expected Not, got: {:?}", expr);
1054        }
1055    }
1056
1057    #[test]
1058    fn test_parse_pipe_not_not() {
1059        let expr = parse_assertion("@empty(.id) | not not");
1060        if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = expr {
1061            assert_eq!(name, "empty");
1062        } else {
1063            panic!(
1064                "Expected bare PluginCall (double negation cancels), got: {:?}",
1065                expr
1066            );
1067        }
1068    }
1069
1070    #[test]
1071    fn test_parse_negation_not_keyword() {
1072        let expr = parse_assertion("not @empty(.id)");
1073        if let AssertionExpr::Not(inner) = expr {
1074            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1075                assert_eq!(name, "empty");
1076            } else {
1077                panic!("Expected PluginCall");
1078            }
1079        } else {
1080            panic!("Expected Not, got: {:?}", expr);
1081        }
1082    }
1083
1084    #[test]
1085    fn test_parse_xor() {
1086        let expr = parse_assertion("@uuid(.id) xor @email(.name)");
1087        if let AssertionExpr::Xor { left, right, .. } = expr {
1088            assert!(matches!(
1089                *left,
1090                AssertionExpr::Atom(Expr::PluginCall { .. })
1091            ));
1092            assert!(matches!(
1093                *right,
1094                AssertionExpr::Atom(Expr::PluginCall { .. })
1095            ));
1096        } else {
1097            panic!("Expected Xor, got: {:?}", expr);
1098        }
1099    }
1100
1101    #[test]
1102    fn test_parse_or() {
1103        let expr = parse_assertion("@uuid(.id) or @email(.name)");
1104        assert!(
1105            matches!(expr, AssertionExpr::Or { .. }),
1106            "Expected Or, got: {:?}",
1107            expr
1108        );
1109    }
1110
1111    #[test]
1112    fn test_parse_and_or_xor_precedence() {
1113        let expr = parse_assertion("@a or @b xor @c and @d");
1114        assert!(
1115            matches!(expr, AssertionExpr::Or { .. }),
1116            "Expected Or, got: {:?}",
1117            expr
1118        );
1119    }
1120
1121    #[test]
1122    fn test_parse_paren_or_in_and() {
1123        let expr = parse_assertion("(@a or @b) and @c");
1124        if let AssertionExpr::And { left, .. } = expr {
1125            assert!(
1126                matches!(*left, AssertionExpr::Paren(_)),
1127                "Left should be Paren(Or)"
1128            );
1129        } else {
1130            panic!("Expected And, got: {:?}", expr);
1131        }
1132    }
1133
1134    #[test]
1135    fn test_parse_negated_paren_or() {
1136        let expr = parse_assertion("!(@empty(.id) or @uuid(.id))");
1137        if let AssertionExpr::Not(inner) = expr {
1138            if let AssertionExpr::Paren(or_expr) = &*inner {
1139                assert!(matches!(**or_expr, AssertionExpr::Or { .. }));
1140            } else {
1141                panic!("Expected Paren(Or), got: {:?}", inner);
1142            }
1143        } else {
1144            panic!("Expected Not, got: {:?}", expr);
1145        }
1146    }
1147
1148    #[test]
1149    fn test_roundtrip_xor() {
1150        let expr = parse_assertion("@a() xor @b()");
1151        let s = assertion_to_string(&expr);
1152        assert!(s.contains(" xor "), "Should contain xor: {}", s);
1153    }
1154
1155    #[test]
1156    fn test_roundtrip_pipe_not() {
1157        let expr = parse_assertion("@empty(.id) | not");
1158        let s = assertion_to_string(&expr);
1159        assert!(s.contains('!'), "Pipe not should serialize as !: {}", s);
1160    }
1161
1162    #[test]
1163    fn test_contains() {
1164        let expr = parse_assertion(".name contains \"test\"");
1165        if let AssertionExpr::Binary { op, .. } = expr {
1166            assert_eq!(op, BinaryOp::Contains);
1167        } else {
1168            panic!("Expected Binary");
1169        }
1170    }
1171
1172    #[test]
1173    fn test_startswith() {
1174        let expr = parse_assertion(".name startsWith \"te\"");
1175        if let AssertionExpr::Binary { op, .. } = expr {
1176            assert_eq!(op, BinaryOp::StartsWith);
1177        } else {
1178            panic!("Expected Binary");
1179        }
1180    }
1181
1182    #[test]
1183    fn test_matches() {
1184        let expr = parse_assertion(".name matches \"^te.*t$\"");
1185        if let AssertionExpr::Binary { op, .. } = expr {
1186            assert_eq!(op, BinaryOp::Matches);
1187        } else {
1188            panic!("Expected Binary");
1189        }
1190    }
1191
1192    #[test]
1193    fn test_if_then_else() {
1194        let expr = parse_assertion("if @len(.items) == 0 then true else false end");
1195        if let AssertionExpr::IfThenElse {
1196            condition,
1197            then_branch,
1198            else_branch,
1199        } = expr
1200        {
1201            assert!(matches!(*condition, AssertionExpr::Binary { .. }));
1202            assert!(matches!(
1203                *then_branch,
1204                AssertionExpr::Atom(Expr::Literal(Literal::Bool(true)))
1205            ));
1206            assert!(matches!(
1207                *else_branch,
1208                AssertionExpr::Atom(Expr::Literal(Literal::Bool(false)))
1209            ));
1210        } else {
1211            panic!("Expected IfThenElse");
1212        }
1213    }
1214
1215    #[test]
1216    fn test_if_then_else_serializes_as_ternary() {
1217        let expr = parse_assertion("if .x == 0 then true else false end");
1218        let s = assertion_to_string(&expr);
1219        assert!(s.contains('?'), "Should contain ternary: {}", s);
1220        assert!(s.contains(':'), "Should contain colon: {}", s);
1221    }
1222
1223    #[test]
1224    fn test_nested_if_serializes_correctly() {
1225        let expr =
1226            parse_assertion("if .a == 1 then if .b == 2 then \"A\" else \"B\" end else \"C\" end");
1227        if let AssertionExpr::IfThenElse {
1228            then_branch,
1229            else_branch,
1230            ..
1231        } = &expr
1232        {
1233            assert!(matches!(**then_branch, AssertionExpr::IfThenElse { .. }));
1234            assert!(matches!(
1235                **else_branch,
1236                AssertionExpr::Atom(Expr::Literal(Literal::Str(_)))
1237            ));
1238        } else {
1239            panic!("Expected IfThenElse");
1240        }
1241        let s = assertion_to_string(&expr);
1242        assert!(s.contains('('), "Nested ternary should have parens: {}", s);
1243    }
1244
1245    #[test]
1246    fn test_remove_redundant_parens() {
1247        let expr = parse_assertion("((.x == 1))");
1248        let simplified = remove_redundant_parens(&expr);
1249        let s = assertion_to_string(&simplified);
1250        assert!(!s.starts_with("(("), "Should not have double parens: {}", s);
1251    }
1252
1253    #[test]
1254    fn test_roundtrip_simple() {
1255        let original = ".id == 123";
1256        let expr = parse_assertion(original);
1257        assert_eq!(assertion_to_string(&expr), original);
1258    }
1259
1260    #[test]
1261    fn test_roundtrip_with_plugin() {
1262        let original = "@len(.items) == 0";
1263        let expr = parse_assertion(original);
1264        assert_eq!(assertion_to_string(&expr), original);
1265    }
1266
1267    #[test]
1268    fn test_parse_and_or() {
1269        assert!(matches!(
1270            parse_assertion(".x == 1 and .y == 2"),
1271            AssertionExpr::And { .. }
1272        ));
1273        assert!(matches!(
1274            parse_assertion(".x == 1 or .y == 2"),
1275            AssertionExpr::Or { .. }
1276        ));
1277    }
1278
1279    #[test]
1280    fn test_parse_not_not() {
1281        if let AssertionExpr::NotNot(inner) = parse_assertion("not not .x") {
1282            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1283        } else {
1284            panic!("Expected NotNot");
1285        }
1286    }
1287
1288    #[test]
1289    fn test_parse_double_bang() {
1290        if let AssertionExpr::NotNot(inner) = parse_assertion("!!.x") {
1291            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1292        } else {
1293            panic!("Expected NotNot");
1294        }
1295    }
1296
1297    #[test]
1298    fn test_parse_regex_literal() {
1299        let expr = parse_assertion("@regex(.name, /^hello/i) == true");
1300        if let AssertionExpr::Binary { op, left, .. } = expr {
1301            assert_eq!(op, BinaryOp::Eq);
1302            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1303                assert_eq!(name, "regex");
1304                assert_eq!(args.len(), 2);
1305                if let AssertionExpr::Atom(a) = &args[1] {
1306                    if let Expr::RegExp { pattern, flags } = &a {
1307                        assert_eq!(pattern, "^hello");
1308                        assert_eq!(flags, "");
1309                    } else {
1310                        panic!("Expected RegExp");
1311                    }
1312                } else {
1313                    panic!("Expected Atom");
1314                }
1315            } else {
1316                panic!("Expected PluginCall");
1317            }
1318        } else {
1319            panic!("Expected Binary");
1320        }
1321    }
1322
1323    #[test]
1324    fn test_parse_regex_serializes_correctly() {
1325        let expr = parse_assertion("@regex(.x, /\\d{4}/gi) == true");
1326        let s = assertion_to_string(&expr);
1327        assert!(s.contains("/\\d{4}/"), "Should contain regex: {}", s);
1328    }
1329
1330    #[test]
1331    fn test_parse_json_literal() {
1332        let expr = parse_assertion("@json(.data) == {\"key\": \"value\"}");
1333        if let AssertionExpr::Binary { op, right, .. } = expr {
1334            assert_eq!(op, BinaryOp::Eq);
1335            if let AssertionExpr::Atom(Expr::Json(s)) = &*right {
1336                assert!(s.contains("\"key\""));
1337                assert!(s.contains("\"value\""));
1338            } else {
1339                panic!("Expected Json");
1340            }
1341        } else {
1342            panic!("Expected Binary");
1343        }
1344    }
1345
1346    #[test]
1347    fn test_nested_ternary_roundtrip() {
1348        let original = "if .x == 1 then if .y == 2 then true else false end else false end";
1349        let expr = parse_assertion(original);
1350        let s = assertion_to_string(&expr);
1351        assert!(s.contains('?'), "Should contain ternary: {}", s);
1352        assert!(s.contains('('), "Should contain parens: {}", s);
1353    }
1354
1355    // ─── Type cast tests ───────────────────────────────────────────────
1356
1357    #[test]
1358    fn test_parse_type_cast_number() {
1359        let expr = parse_assertion(".price:number >= 0");
1360        if let AssertionExpr::Binary { op, left, right } = expr {
1361            assert_eq!(op, BinaryOp::Ge);
1362            assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "number"));
1363            if let AssertionExpr::Atom(Expr::As(inner, tn)) = &*left {
1364                assert_eq!(tn, "number");
1365                assert!(matches!(&**inner, Expr::JqPath(p) if p == ".price"));
1366            }
1367            assert!(
1368                matches!(&*right, AssertionExpr::Atom(Expr::Literal(Literal::Number(n))) if n == "0")
1369            );
1370        } else {
1371            panic!("Expected Binary, got: {:?}", expr);
1372        }
1373    }
1374
1375    #[test]
1376    fn test_parse_type_cast_string() {
1377        let expr = parse_assertion(".name:string contains \"hello\"");
1378        assert!(matches!(
1379            expr,
1380            AssertionExpr::Binary {
1381                op: BinaryOp::Contains,
1382                ..
1383            }
1384        ));
1385    }
1386
1387    #[test]
1388    fn test_parse_type_cast_uint() {
1389        let expr = parse_assertion("@len(.items):uint > 0");
1390        if let AssertionExpr::Binary { op, left, .. } = expr {
1391            assert_eq!(op, BinaryOp::Gt);
1392            assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "uint"));
1393        } else {
1394            panic!("Expected Binary, got: {:?}", expr);
1395        }
1396    }
1397
1398    #[test]
1399    fn test_parse_type_cast_bool() {
1400        let expr = parse_assertion(".active:bool == true");
1401        if let AssertionExpr::Binary { op, left, right } = expr {
1402            assert_eq!(op, BinaryOp::Eq);
1403            assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "bool"));
1404            assert!(matches!(
1405                &*right,
1406                AssertionExpr::Atom(Expr::Literal(Literal::Bool(true)))
1407            ));
1408        } else {
1409            panic!("Expected Binary, got: {:?}", expr);
1410        }
1411    }
1412
1413    #[test]
1414    fn test_parse_type_cast_all_types_parsed() {
1415        let types = [
1416            "bool", "uint", "number", "string", "json", "yaml", "uuid", "email", "url", "ip",
1417        ];
1418        for type_name in &types {
1419            let raw = format!(".x:{} == 0", type_name);
1420            let expr = parse_assertion(&raw);
1421            assert!(
1422                matches!(&expr, AssertionExpr::Binary { left, .. }
1423                    if matches!(&**left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == type_name)),
1424                "Failed for type: {}",
1425                type_name
1426            );
1427        }
1428    }
1429
1430    #[test]
1431    fn test_parse_type_cast_roundtrip() {
1432        let original = ".price:number >= 0";
1433        let expr = parse_assertion(original);
1434        let s = assertion_to_string(&expr);
1435        assert_eq!(s, original);
1436    }
1437
1438    #[test]
1439    fn test_parse_type_cast_compound() {
1440        let expr =
1441            parse_assertion(r#".ips_to_decorations["10.0.0.1"].environment == "production""#);
1442        assert_eq!(
1443            assertion_to_string(&expr),
1444            r#".ips_to_decorations["10.0.0.1"].environment == "production""#
1445        );
1446
1447        let expr = parse_assertion(".items:json == {\"key\": \"value\"}");
1448        if let AssertionExpr::Binary { op, left, right } = expr {
1449            assert_eq!(op, BinaryOp::Eq);
1450            assert!(matches!(&*left, AssertionExpr::Atom(Expr::As(_, tn)) if tn == "json"));
1451            assert!(matches!(&*right, AssertionExpr::Atom(Expr::Json(_))));
1452        } else {
1453            panic!("Expected Binary, got: {:?}", expr);
1454        }
1455    }
1456
1457    #[test]
1458    fn test_parse_type_cast_jq_path_preserved() {
1459        let expr = parse_assertion(".user.name:string == \"alice\"");
1460        if let AssertionExpr::Binary { left, .. } = &expr {
1461            if let AssertionExpr::Atom(Expr::As(inner, tn)) = &**left {
1462                assert_eq!(tn, "string");
1463                assert!(matches!(&**inner, Expr::JqPath(p) if p == ".user.name"));
1464            } else {
1465                panic!("Expected As, got: {:?}", left);
1466            }
1467        } else {
1468            panic!("Expected Binary, got: {:?}", expr);
1469        }
1470    }
1471
1472    #[test]
1473    fn test_parse_type_cast_assertion_to_string_preserves_cast() {
1474        let cases = [
1475            ".x:number > 0",
1476            ".x:string == \"hello\"",
1477            ".x:bool == true",
1478            ".x:uint >= 5",
1479        ];
1480        for original in &cases {
1481            let expr = parse_assertion(original);
1482            let s = assertion_to_string(&expr);
1483            assert_eq!(s.as_str(), *original, "Roundtrip failed for: {}", original);
1484        }
1485    }
1486
1487    #[test]
1488    fn test_bracket_with_template_string() {
1489        let expr = parse_assertion(r#".x["{{var}}"] == "val""#);
1490        if let AssertionExpr::Binary { left, .. } = &expr {
1491            if let AssertionExpr::Atom(Expr::JqPath(p)) = &**left {
1492                assert_eq!(p, r#".x["{{var}}"]"#);
1493            } else {
1494                panic!("Expected JqPath, got: {:?}", left);
1495            }
1496        } else {
1497            panic!("Expected Binary, got: {:?}", expr);
1498        }
1499        let s = assertion_to_string(&expr);
1500        assert_eq!(s, r#".x["{{var}}"] == "val""#);
1501    }
1502
1503    #[test]
1504    fn test_bracket_with_var_index() {
1505        let expr = parse_assertion(r#".x[.idx] == 0"#);
1506        if let AssertionExpr::Binary { left, .. } = &expr {
1507            if let AssertionExpr::Atom(Expr::JqPath(p)) = &**left {
1508                assert_eq!(p, ".x[.idx]", "path mismatch, full expr: {:?}", expr);
1509            } else {
1510                panic!("Expected JqPath, got: {:?}", left);
1511            }
1512        } else {
1513            panic!("Expected Binary, got: {:?}", expr);
1514        }
1515        let s = assertion_to_string(&expr);
1516        assert_eq!(s, ".x[.idx] == 0");
1517    }
1518
1519    #[test]
1520    fn test_type_method_syntax() {
1521        let expr = parse_assertion(r#"@url.scheme(.webhook) == "https""#);
1522        if let AssertionExpr::Binary { left, .. } = &expr {
1523            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &**left {
1524                assert_eq!(name, "url.scheme");
1525                assert_eq!(args.len(), 1);
1526            } else {
1527                panic!("Expected PluginCall, got: {:?}", left);
1528            }
1529        } else {
1530            panic!("Expected Binary, got: {:?}", expr);
1531        }
1532        let s = assertion_to_string(&expr);
1533        assert_eq!(s, r#"@url.scheme(.webhook) == "https""#);
1534    }
1535
1536    #[test]
1537    fn test_type_method_roundtrip() {
1538        let cases = [
1539            "@email.domain(.x) == \"example.com\"",
1540            "@json.key(.config, \"timeout\") == 30",
1541            "@regexp.test(.pattern, \"input\")",
1542        ];
1543        for original in &cases {
1544            let expr = parse_assertion(original);
1545            let s = assertion_to_string(&expr);
1546            assert_eq!(s.as_str(), *original, "Roundtrip failed for: {}", original);
1547        }
1548    }
1549
1550    #[test]
1551    fn test_parse_type_cast_with_plugin() {
1552        let expr = parse_assertion("@len(.items):number >= 0");
1553        if let AssertionExpr::Binary { left, .. } = &expr {
1554            if let AssertionExpr::Atom(Expr::As(inner, tn)) = &**left {
1555                assert_eq!(tn, "number");
1556                assert!(matches!(&**inner, Expr::PluginCall { name, .. } if name == "len"));
1557            } else {
1558                panic!("Expected As(PluginCall), got: {:?}", left);
1559            }
1560        } else {
1561            panic!("Expected Binary, got: {:?}", expr);
1562        }
1563    }
1564}