• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gripmock / grpctestify-rust / 24848875158

23 Apr 2026 05:19PM UTC coverage: 77.72% (+0.8%) from 76.897%
24848875158

Pull #42

github

web-flow
Merge 5ad12294b into 59e77d08a
Pull Request #42: bump rustc & command grpcurl (exp)

1067 of 1259 new or added lines in 25 files covered. (84.75%)

95 existing lines in 7 files now uncovered.

18861 of 24268 relevant lines covered (77.72%)

40959.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.86
/src/parser/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

16
use serde::{Deserialize, Serialize};
17

18
use super::tokenizer::{TokenKind, tokenize_assertion};
19

20
/// A complete assertion expression (top-level).
21
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22
pub 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)]
54
pub 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
}
69

70
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71
pub enum Literal {
72
    Bool(bool),
73
    Number(String),
74
    Str(String),
75
    Null,
76
}
77

78
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79
pub enum BinaryOp {
80
    Eq,
81
    Ne,
82
    Gt,
83
    Lt,
84
    Ge,
85
    Le,
86
    Contains,
87
    Matches,
88
    StartsWith,
89
    EndsWith,
90
}
91

92
impl BinaryOp {
93
    pub fn as_str(&self) -> &'static str {
800,315✔
94
        match self {
800,315✔
95
            Self::Eq => "==",
600,194✔
96
            Self::Ne => "!=",
65✔
97
            Self::Gt => ">",
100,026✔
98
            Self::Lt => "<",
×
99
            Self::Ge => ">=",
100,008✔
100
            Self::Le => "<=",
×
101
            Self::Contains => "contains",
×
102
            Self::Matches => "matches",
4✔
103
            Self::StartsWith => "startsWith",
18✔
104
            Self::EndsWith => "endsWith",
×
105
        }
106
    }
800,315✔
UNCOV
107
    fn try_parse(s: &str) -> Option<Self> {
×
UNCOV
108
        match s {
×
UNCOV
109
            "==" => Some(Self::Eq),
×
UNCOV
110
            "!=" => Some(Self::Ne),
×
UNCOV
111
            ">" => Some(Self::Gt),
×
UNCOV
112
            "<" => Some(Self::Lt),
×
UNCOV
113
            ">=" => Some(Self::Ge),
×
UNCOV
114
            "<=" => Some(Self::Le),
×
UNCOV
115
            "contains" => Some(Self::Contains),
×
UNCOV
116
            "matches" => Some(Self::Matches),
×
UNCOV
117
            "startsWith" | "startswith" => Some(Self::StartsWith),
×
UNCOV
118
            "endsWith" | "endswith" => Some(Self::EndsWith),
×
119
            _ => None,
×
120
        }
UNCOV
121
    }
×
122
}
123

124
// ─── Display ───────────────────────────────────────────────────────────
125

126
impl std::fmt::Display for Expr {
127
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
939✔
128
        match self {
524✔
129
            Self::JqPath(p) => write!(f, "{}", p),
180✔
130
            Self::PluginCall { name, args } => {
215✔
131
                write!(f, "@{}(", name)?;
215✔
132
                for (i, a) in args.iter().enumerate() {
215✔
133
                    if i > 0 {
213✔
134
                        write!(f, ", ")?;
2✔
135
                    }
211✔
136
                    write!(f, "{}", a)?;
213✔
137
                }
138
                write!(f, ")")
215✔
139
            }
140
            Self::Literal(Literal::Bool(b)) => write!(f, "{}", b),
134✔
141
            Self::Literal(Literal::Number(n)) => write!(f, "{}", n),
139✔
142
            Self::Literal(Literal::Str(s)) => write!(f, "\"{}\"", s),
251✔
143
            Self::Literal(Literal::Null) => write!(f, "null"),
×
UNCOV
144
            Self::RegExp { pattern, flags } => {
×
UNCOV
145
                write!(f, "/{}/", pattern)?;
×
UNCOV
146
                if !flags.is_empty() {
×
147
                    write!(f, "{}", flags)?;
×
UNCOV
148
                }
×
UNCOV
149
                Ok(())
×
150
            }
151
            Self::Json(s) => write!(f, "{}", s),
×
152
            Self::Yaml(s) => write!(f, "{}", s),
×
153
            Self::Variable(n) => write!(f, "{{{{{}}}}}", n),
20✔
154
        }
155
    }
939✔
156
}
157

158
impl std::fmt::Display for AssertionExpr {
159
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325✔
160
        fmt_assertion(self, f, 0)
325✔
161
    }
325✔
162
}
163

164
fn fmt_assertion(e: &AssertionExpr, f: &mut std::fmt::Formatter<'_>, prec: u8) -> std::fmt::Result {
325✔
165
    match e {
325✔
166
        AssertionExpr::Or { left, right } => {
×
167
            if prec > 1 {
×
168
                write!(f, "(")?;
×
169
            }
×
170
            fmt_assertion(left, f, 1)?;
×
171
            write!(f, " or ")?;
×
172
            fmt_assertion(right, f, 1)?;
×
173
            if prec > 1 {
×
174
                write!(f, ")")?;
×
175
            }
×
176
            Ok(())
×
177
        }
UNCOV
178
        AssertionExpr::Xor { left, right } => {
×
UNCOV
179
            if prec > 1 {
×
180
                write!(f, "(")?;
×
UNCOV
181
            }
×
UNCOV
182
            fmt_assertion(left, f, 1)?;
×
UNCOV
183
            write!(f, " xor ")?;
×
UNCOV
184
            fmt_assertion(right, f, 1)?;
×
UNCOV
185
            if prec > 1 {
×
186
                write!(f, ")")?;
×
UNCOV
187
            }
×
UNCOV
188
            Ok(())
×
189
        }
190
        AssertionExpr::And { left, right } => {
×
191
            if prec > 2 {
×
192
                write!(f, "(")?;
×
193
            }
×
194
            fmt_assertion(left, f, 2)?;
×
195
            write!(f, " and ")?;
×
196
            fmt_assertion(right, f, 2)?;
×
197
            if prec > 2 {
×
198
                write!(f, ")")?;
×
199
            }
×
200
            Ok(())
×
201
        }
UNCOV
202
        AssertionExpr::Binary { op, left, right } => {
×
UNCOV
203
            if prec > 3 {
×
204
                write!(f, "(")?;
×
UNCOV
205
            }
×
UNCOV
206
            fmt_assertion(left, f, 3)?;
×
UNCOV
207
            write!(f, " {} ", op.as_str())?;
×
UNCOV
208
            fmt_assertion(right, f, 3)?;
×
UNCOV
209
            if prec > 3 {
×
210
                write!(f, ")")?;
×
UNCOV
211
            }
×
UNCOV
212
            Ok(())
×
213
        }
UNCOV
214
        AssertionExpr::Not(inner) => {
×
UNCOV
215
            write!(f, "!")?;
×
UNCOV
216
            fmt_assertion(inner, f, 4)
×
217
        }
218
        AssertionExpr::NotNot(inner) => {
×
219
            write!(f, "not not ")?;
×
220
            fmt_assertion(inner, f, 4)
×
221
        }
222
        AssertionExpr::IfThenElse {
UNCOV
223
            condition,
×
UNCOV
224
            then_branch,
×
UNCOV
225
            else_branch,
×
226
        } => {
UNCOV
227
            write!(f, "(")?;
×
UNCOV
228
            fmt_assertion(condition, f, 0)?;
×
UNCOV
229
            write!(f, " ? ")?;
×
UNCOV
230
            fmt_assertion(then_branch, f, 0)?;
×
UNCOV
231
            write!(f, " : ")?;
×
UNCOV
232
            fmt_assertion(else_branch, f, 0)?;
×
UNCOV
233
            write!(f, ")")
×
234
        }
235
        AssertionExpr::Paren(inner) => {
×
236
            write!(f, "(")?;
×
237
            fmt_assertion(inner, f, 0)?;
×
238
            write!(f, ")")
×
239
        }
240
        AssertionExpr::Atom(e) => write!(f, "{}", e),
325✔
241
        AssertionExpr::Raw(s) => write!(f, "{}", s),
×
242
    }
243
}
325✔
244

245
// ─── Parser ────────────────────────────────────────────────────────────
246

247
/// Parse a raw assertion string into an AST.
248
/// Falls back to `Raw` if parsing fails.
249
pub fn parse_assertion(raw: &str) -> AssertionExpr {
400,744✔
250
    let tokens = tokenize_assertion(raw);
400,744✔
251
    if tokens.is_empty() {
400,744✔
252
        return AssertionExpr::Raw(raw.to_string());
×
253
    }
400,744✔
254
    let mut pos = 0;
400,744✔
255
    let expr = parse_pipe(&tokens, &mut pos);
400,744✔
256
    if pos >= tokens.len() {
400,744✔
257
        expr
400,741✔
258
    } else {
259
        AssertionExpr::Raw(raw.to_string())
3✔
260
    }
261
}
400,744✔
262

263
fn parse_pipe(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
2,301,412✔
264
    let mut expr = parse_or(ts, p);
2,301,412✔
265
    while *p < ts.len() {
2,301,418✔
266
        if !matches!(ts[*p].kind, TokenKind::Pipe) {
1,600,677✔
267
            break;
1,600,670✔
268
        }
7✔
269
        *p += 1;
7✔
270
        if *p < ts.len() && is_keyword(ts, *p, "not") {
7✔
271
            *p += 1;
6✔
272
            if *p < ts.len() && is_keyword(ts, *p, "not") {
6✔
273
                *p += 1;
2✔
274
            } else {
4✔
275
                expr = AssertionExpr::Not(Box::new(expr));
4✔
276
            }
4✔
277
        } else {
278
            break;
1✔
279
        }
280
    }
281
    expr
2,301,412✔
282
}
2,301,412✔
283

284
fn parse_or(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
2,301,412✔
285
    let mut left = parse_xor(ts, p);
2,301,412✔
286
    while *p < ts.len() && is_keyword(ts, *p, "or") {
2,501,437✔
287
        *p += 1;
200,025✔
288
        let right = parse_xor(ts, p);
200,025✔
289
        left = AssertionExpr::Or {
200,025✔
290
            left: Box::new(left),
200,025✔
291
            right: Box::new(right),
200,025✔
292
        };
200,025✔
293
    }
200,025✔
294
    left
2,301,412✔
295
}
2,301,412✔
296

297
fn parse_xor(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
2,501,437✔
298
    let mut left = parse_and(ts, p);
2,501,437✔
299
    while *p < ts.len() && is_keyword(ts, *p, "xor") {
2,501,443✔
300
        *p += 1;
6✔
301
        let right = parse_and(ts, p);
6✔
302
        left = AssertionExpr::Xor {
6✔
303
            left: Box::new(left),
6✔
304
            right: Box::new(right),
6✔
305
        };
6✔
306
    }
6✔
307
    left
2,501,437✔
308
}
2,501,437✔
309

310
fn parse_and(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
2,501,443✔
311
    let mut left = parse_bin(ts, p);
2,501,443✔
312
    while *p < ts.len() && is_keyword(ts, *p, "and") {
3,201,467✔
313
        *p += 1;
700,024✔
314
        let right = parse_bin(ts, p);
700,024✔
315
        left = AssertionExpr::And {
700,024✔
316
            left: Box::new(left),
700,024✔
317
            right: Box::new(right),
700,024✔
318
        };
700,024✔
319
    }
700,024✔
320
    left
2,501,443✔
321
}
2,501,443✔
322

323
fn parse_bin(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
3,201,467✔
324
    let mut left = parse_unary(ts, p);
3,201,467✔
325
    loop {
326
        let op = match ts.get(*p).map(|t| &t.kind) {
5,001,848✔
327
            Some(TokenKind::Op(s)) => match s.as_str() {
1,800,381✔
328
                "==" => BinaryOp::Eq,
1,800,381✔
329
                "!=" => BinaryOp::Ne,
400,146✔
330
                ">" => BinaryOp::Gt,
400,087✔
331
                "<" => BinaryOp::Lt,
200,054✔
332
                ">=" => BinaryOp::Ge,
200,052✔
333
                "<=" => BinaryOp::Le,
37✔
334
                "contains" => BinaryOp::Contains,
36✔
335
                "matches" => BinaryOp::Matches,
30✔
336
                "startsWith" | "startswith" => BinaryOp::StartsWith,
24✔
337
                "endsWith" | "endswith" => BinaryOp::EndsWith,
1✔
NEW
338
                _ => BinaryOp::EndsWith,
×
339
            },
340
            Some(TokenKind::Ident(s)) if is_bin_op_keyword(s) => {
1,500,241✔
NEW
341
                BinaryOp::try_parse(s).unwrap_or(BinaryOp::EndsWith)
×
342
            }
343
            None => break,
700,735✔
344
            _ => break,
2,500,732✔
345
        };
346

347
        *p += 1;
1,800,381✔
348
        let right = parse_unary(ts, p);
1,800,381✔
349
        left = AssertionExpr::Binary {
1,800,381✔
350
            op,
1,800,381✔
351
            left: Box::new(left),
1,800,381✔
352
            right: Box::new(right),
1,800,381✔
353
        };
1,800,381✔
354
    }
355
    left
3,201,467✔
356
}
3,201,467✔
357

358
fn parse_unary(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
5,001,945✔
359
    if *p >= ts.len() {
5,001,945✔
360
        return AssertionExpr::Raw(String::new());
×
361
    }
5,001,945✔
362
    match &ts[*p].kind {
5,001,945✔
363
        TokenKind::Bang => {
364
            *p += 1;
64✔
365
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Bang) {
64✔
366
                *p += 1;
18✔
367
                let inner = parse_unary(ts, p);
18✔
368
                AssertionExpr::NotNot(Box::new(inner))
18✔
369
            } else {
370
                let inner = parse_unary(ts, p);
46✔
371
                AssertionExpr::Not(Box::new(inner))
46✔
372
            }
373
        }
374
        TokenKind::Ident(s) if s == "not" => {
900,321✔
375
            *p += 1;
33✔
376
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Ident(ref s2) if s2 == "not") {
33✔
377
                *p += 1;
16✔
378
                let inner = parse_unary(ts, p);
16✔
379
                AssertionExpr::NotNot(Box::new(inner))
16✔
380
            } else {
381
                let inner = parse_unary(ts, p);
17✔
382
                AssertionExpr::Not(Box::new(inner))
17✔
383
            }
384
        }
385
        TokenKind::Ident(s) if s == "if" => parse_if(ts, p),
900,288✔
386
        TokenKind::LParen => {
387
            *p += 1;
400,040✔
388
            let inner = parse_pipe(ts, p);
400,040✔
389
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::RParen) {
400,040✔
390
                *p += 1;
400,040✔
391
            }
400,040✔
392
            AssertionExpr::Paren(Box::new(inner))
400,040✔
393
        }
394
        _ => parse_atom(ts, p),
4,401,746✔
395
    }
396
}
5,001,945✔
397

398
fn parse_if(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
200,062✔
399
    *p += 1;
200,062✔
400
    let cond = parse_pipe(ts, p);
200,062✔
401
    if *p >= ts.len() || !is_keyword(ts, *p, "then") {
200,062✔
402
        return AssertionExpr::Raw("if..then missing".into());
×
403
    }
200,062✔
404
    *p += 1;
200,062✔
405
    let then_b = parse_pipe(ts, p);
200,062✔
406
    if *p >= ts.len() || !is_keyword(ts, *p, "else") {
200,062✔
407
        return AssertionExpr::Raw("if..else missing".into());
×
408
    }
200,062✔
409
    *p += 1;
200,062✔
410
    let else_b = parse_pipe(ts, p);
200,062✔
411
    if *p < ts.len() && is_keyword(ts, *p, "end") {
200,062✔
412
        *p += 1;
200,062✔
413
    }
200,062✔
414
    AssertionExpr::IfThenElse {
200,062✔
415
        condition: Box::new(cond),
200,062✔
416
        then_branch: Box::new(then_b),
200,062✔
417
        else_branch: Box::new(else_b),
200,062✔
418
    }
200,062✔
419
}
200,062✔
420

421
fn parse_atom(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
4,401,746✔
422
    if *p >= ts.len() {
4,401,746✔
423
        return AssertionExpr::Atom(Expr::JqPath(String::new()));
×
424
    }
4,401,746✔
425
    let expr = match &ts[*p].kind {
4,401,746✔
426
        TokenKind::StringLit(s) => {
200,440✔
427
            *p += 1;
200,440✔
428
            Expr::Literal(Literal::Str(s.clone()))
200,440✔
429
        }
430
        TokenKind::NumberLit(n) => {
900,222✔
431
            *p += 1;
900,222✔
432
            Expr::Literal(Literal::Number(n.clone()))
900,222✔
433
        }
434
        TokenKind::Ident(s) if s == "true" => {
700,226✔
435
            *p += 1;
700,169✔
436
            Expr::Literal(Literal::Bool(true))
700,169✔
437
        }
438
        TokenKind::Ident(s) if s == "false" => {
57✔
439
            *p += 1;
56✔
440
            Expr::Literal(Literal::Bool(false))
56✔
441
        }
442
        TokenKind::Ident(s) if s == "null" => {
1✔
443
            *p += 1;
×
444
            Expr::Literal(Literal::Null)
×
445
        }
446
        TokenKind::VarDelim => {
447
            *p += 1;
200,020✔
448
            let mut name = String::with_capacity(16);
200,020✔
449
            while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::VarDelim) {
400,040✔
450
                if let TokenKind::Ident(s) = &ts[*p].kind {
200,020✔
451
                    if !name.is_empty() {
200,020✔
452
                        name.push(' ');
×
453
                    }
200,020✔
454
                    name.push_str(s);
200,020✔
455
                }
×
456
                *p += 1;
200,020✔
457
            }
458
            if *p < ts.len() {
200,020✔
459
                *p += 1;
200,020✔
460
            }
200,020✔
461
            Expr::Variable(name)
200,020✔
462
        }
463
        TokenKind::At => {
464
            *p += 1;
400,451✔
465
            let name = if *p < ts.len() {
400,451✔
466
                if let TokenKind::Ident(s) = &ts[*p].kind {
400,451✔
467
                    *p += 1;
400,451✔
468
                    s.clone()
400,451✔
469
                } else {
470
                    String::new()
×
471
                }
472
            } else {
473
                String::new()
×
474
            };
475
            let args = if *p < ts.len() && matches!(ts[*p].kind, TokenKind::LParen) {
400,451✔
476
                *p += 1;
400,444✔
477
                let mut args = Vec::with_capacity(4);
400,444✔
478
                while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RParen) {
1,000,886✔
479
                    let arg = parse_pipe(ts, p);
600,442✔
480
                    args.push(arg);
600,442✔
481
                    if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Comma) {
600,442✔
482
                        *p += 1;
200,004✔
483
                    }
400,438✔
484
                }
485
                if *p < ts.len() {
400,444✔
486
                    *p += 1;
400,444✔
487
                }
400,444✔
488
                merge_hyphenated_args(args)
400,444✔
489
            } else {
490
                Vec::new()
7✔
491
            };
492
            Expr::PluginCall { name, args }
400,451✔
493
        }
494
        TokenKind::RegExpLit { pattern, flags } => {
200,003✔
495
            *p += 1;
200,003✔
496
            Expr::RegExp {
200,003✔
497
                pattern: pattern.clone(),
200,003✔
498
                flags: flags.clone(),
200,003✔
499
            }
200,003✔
500
        }
501
        TokenKind::LBrace => {
502
            *p += 1;
1✔
503
            let mut json = String::with_capacity(64);
1✔
504
            json.push('{');
1✔
505
            let mut depth = 1;
1✔
506
            while *p < ts.len() && depth > 0 {
4✔
507
                match &ts[*p].kind {
3✔
508
                    TokenKind::LBrace => {
×
509
                        depth += 1;
×
510
                        json.push('{');
×
511
                        *p += 1;
×
512
                    }
×
513
                    TokenKind::RBrace => {
514
                        depth -= 1;
1✔
515
                        if depth > 0 {
1✔
516
                            json.push('}');
×
517
                        }
1✔
518
                        *p += 1;
1✔
519
                    }
520
                    TokenKind::LParen => {
×
521
                        json.push('(');
×
522
                        *p += 1;
×
523
                    }
×
524
                    TokenKind::RParen => {
×
525
                        json.push(')');
×
526
                        *p += 1;
×
527
                    }
×
528
                    TokenKind::LBracket => {
×
529
                        json.push('[');
×
530
                        *p += 1;
×
531
                    }
×
532
                    TokenKind::RBracket => {
×
533
                        json.push(']');
×
534
                        *p += 1;
×
535
                    }
×
536
                    TokenKind::StringLit(s) => {
2✔
537
                        json.push('"');
2✔
538
                        json.push_str(s);
2✔
539
                        json.push('"');
2✔
540
                        *p += 1;
2✔
541
                    }
2✔
542
                    TokenKind::NumberLit(n) => {
×
543
                        json.push_str(n);
×
544
                        *p += 1;
×
545
                    }
×
546
                    TokenKind::Ident(s) => {
×
547
                        json.push_str(s);
×
548
                        *p += 1;
×
549
                    }
×
550
                    TokenKind::Comma => {
×
551
                        json.push(',');
×
552
                        *p += 1;
×
553
                    }
×
554
                    TokenKind::Op(s) => {
×
555
                        json.push_str(s);
×
556
                        *p += 1;
×
557
                    }
×
558
                    TokenKind::Dot => {
×
559
                        json.push('.');
×
560
                        *p += 1;
×
561
                    }
×
562
                    TokenKind::At => {
×
563
                        json.push('@');
×
564
                        *p += 1;
×
565
                    }
×
566
                    TokenKind::VarDelim => {
×
567
                        json.push_str("{{ }}");
×
568
                        *p += 1;
×
569
                    }
×
570
                    _ => {
×
571
                        *p += 1;
×
572
                    }
×
573
                }
574
            }
575
            json.push('}');
1✔
576
            Expr::Json(json)
1✔
577
        }
578
        TokenKind::Dot | TokenKind::Ident(_) => {
579
            let mut path = String::with_capacity(24);
1,800,384✔
580
            while *p < ts.len() {
5,801,184✔
581
                if let TokenKind::Ident(s) = &ts[*p].kind
5,801,177✔
582
                    && (is_bin_op_keyword(s) || is_keyword_token(&ts[*p].kind))
2,000,397✔
583
                {
584
                    break;
×
585
                }
5,801,177✔
586
                match &ts[*p].kind {
5,801,177✔
587
                    TokenKind::Dot => {
2,000,396✔
588
                        path.push('.');
2,000,396✔
589
                        *p += 1;
2,000,396✔
590
                    }
2,000,396✔
591
                    TokenKind::Ident(s) => {
2,000,397✔
592
                        path.push_str(s);
2,000,397✔
593
                        *p += 1;
2,000,397✔
594
                    }
2,000,397✔
595
                    TokenKind::LBracket => {
596
                        path.push('[');
7✔
597
                        *p += 1;
7✔
598
                        while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RBracket) {
14✔
599
                            if let TokenKind::NumberLit(n) = &ts[*p].kind {
7✔
600
                                path.push_str(n);
3✔
601
                            } else if let TokenKind::Ident(s) = &ts[*p].kind {
4✔
602
                                path.push_str(s);
×
603
                            }
4✔
604
                            *p += 1;
7✔
605
                        }
606
                        if *p < ts.len() {
7✔
607
                            path.push(']');
7✔
608
                            *p += 1;
7✔
609
                        }
7✔
610
                    }
611
                    _ => break,
1,800,377✔
612
                }
613
            }
614
            Expr::JqPath(path)
1,800,384✔
615
        }
616
        _ => {
617
            *p += 1;
×
618
            Expr::JqPath(String::new())
×
619
        }
620
    };
621
    AssertionExpr::Atom(expr)
4,401,746✔
622
}
4,401,746✔
623

624
fn is_keyword(ts: &[super::tokenizer::Token], idx: usize, kw: &str) -> bool {
6,702,337✔
625
    matches!(ts.get(idx), Some(t) if matches!(&t.kind, TokenKind::Ident(s) if s == kw))
6,702,337✔
626
}
6,702,337✔
627

628
/// Merge consecutive bare JqPath args that were split by `-`.
629
/// e.g. `[JqPath("content"), JqPath("type")]` → `[JqPath("content-type")]`
630
fn merge_hyphenated_args(args: Vec<AssertionExpr>) -> Vec<AssertionExpr> {
400,444✔
631
    if args.len() <= 1 {
400,444✔
632
        return args;
200,440✔
633
    }
200,004✔
634

635
    let mut merged = Vec::with_capacity(args.len());
200,004✔
636

637
    let mut iter = args.into_iter().peekable();
200,004✔
638
    while let Some(current) = iter.next() {
600,012✔
639
        match current {
200,004✔
640
            AssertionExpr::Atom(Expr::JqPath(mut cur)) if !cur.contains('.') => {
200,004✔
NEW
641
                while let Some(AssertionExpr::Atom(Expr::JqPath(nxt))) = iter.peek() {
×
NEW
642
                    if nxt.contains('.') {
×
NEW
643
                        break;
×
NEW
644
                    }
×
645

NEW
646
                    let Some(AssertionExpr::Atom(Expr::JqPath(nxt_owned))) = iter.next() else {
×
NEW
647
                        break;
×
648
                    };
649

NEW
650
                    cur.push('-');
×
NEW
651
                    cur.push_str(&nxt_owned);
×
652
                }
653

NEW
654
                merged.push(AssertionExpr::Atom(Expr::JqPath(cur)));
×
655
            }
656
            other => merged.push(other),
400,008✔
657
        }
658
    }
659

660
    merged
200,004✔
661
}
400,444✔
662

663
fn is_bin_op_keyword(s: &str) -> bool {
3,500,638✔
664
    matches!(
×
665
        s,
3,500,638✔
666
        "contains" | "matches" | "startsWith" | "endsWith" | "startswith" | "endswith"
3,500,638✔
667
    )
668
}
3,500,638✔
669

670
fn is_keyword_token(k: &TokenKind) -> bool {
2,000,397✔
671
    matches!(
×
672
        k,
2,000,397✔
673
        TokenKind::Ident(s)
2,000,397✔
674
            if matches!(
×
675
                s.as_str(),
2,000,397✔
676
                "and" | "or" | "xor" | "contains" | "matches" | "startsWith"
2,000,397✔
677
                    | "endsWith" | "startswith" | "endswith"
2,000,397✔
678
            )
679
    )
680
}
2,000,397✔
681

682
// ─── Public helpers ─────────────────────────────────────────────────────
683

684
/// Convert AssertionExpr back to string (ternary for if-then-else).
685
pub fn assertion_to_string(expr: &AssertionExpr) -> String {
300,009✔
686
    let mut out = String::with_capacity(64);
300,009✔
687
    push_assertion(expr, &mut out, 0);
300,009✔
688
    out
300,009✔
689
}
300,009✔
690

691
fn push_expr(expr: &Expr, out: &mut String) {
2,000,033✔
692
    match expr {
800,017✔
693
        Expr::JqPath(p) => out.push_str(p),
800,010✔
694
        Expr::PluginCall { name, args } => {
200,005✔
695
            out.push('@');
200,005✔
696
            out.push_str(name);
200,005✔
697
            out.push('(');
200,005✔
698
            for (i, a) in args.iter().enumerate() {
300,004✔
699
                if i > 0 {
300,004✔
700
                    out.push_str(", ");
100,001✔
701
                }
200,003✔
702
                push_assertion(a, out, 0);
300,004✔
703
            }
704
            out.push(')');
200,005✔
705
        }
706
        Expr::Literal(Literal::Bool(b)) => out.push_str(if *b { "true" } else { "false" }),
300,006✔
707
        Expr::Literal(Literal::Number(n)) => out.push_str(n),
400,008✔
708
        Expr::Literal(Literal::Str(s)) => {
100,003✔
709
            out.push('"');
100,003✔
710
            out.push_str(s);
100,003✔
711
            out.push('"');
100,003✔
712
        }
100,003✔
NEW
713
        Expr::Literal(Literal::Null) => out.push_str("null"),
×
714
        Expr::Variable(n) => {
100,000✔
715
            out.push_str("{{");
100,000✔
716
            out.push_str(n);
100,000✔
717
            out.push_str("}}");
100,000✔
718
        }
100,000✔
719
        Expr::RegExp { pattern, flags } => {
100,001✔
720
            out.push('/');
100,001✔
721
            out.push_str(pattern);
100,001✔
722
            out.push('/');
100,001✔
723
            out.push_str(flags);
100,001✔
724
        }
100,001✔
NEW
725
        Expr::Json(s) | Expr::Yaml(s) => out.push_str(s),
×
726
    }
727
}
2,000,033✔
728

729
fn push_assertion(expr: &AssertionExpr, out: &mut String, prec: u8) {
3,500,049✔
730
    match expr {
3,500,049✔
731
        AssertionExpr::Or { left, right } => {
100,000✔
732
            if prec > 1 {
100,000✔
NEW
733
                out.push('(');
×
734
            }
100,000✔
735
            push_assertion(left, out, 1);
100,000✔
736
            out.push_str(" or ");
100,000✔
737
            push_assertion(right, out, 1);
100,000✔
738
            if prec > 1 {
100,000✔
NEW
739
                out.push(')');
×
740
            }
100,000✔
741
        }
742
        AssertionExpr::Xor { left, right } => {
1✔
743
            if prec > 1 {
1✔
NEW
744
                out.push('(');
×
745
            }
1✔
746
            push_assertion(left, out, 1);
1✔
747
            out.push_str(" xor ");
1✔
748
            push_assertion(right, out, 1);
1✔
749
            if prec > 1 {
1✔
NEW
750
                out.push(')');
×
751
            }
1✔
752
        }
753
        AssertionExpr::And { left, right } => {
300,000✔
754
            if prec > 2 {
300,000✔
NEW
755
                out.push('(');
×
756
            }
300,000✔
757
            push_assertion(left, out, 2);
300,000✔
758
            out.push_str(" and ");
300,000✔
759
            push_assertion(right, out, 2);
300,000✔
760
            if prec > 2 {
300,000✔
NEW
761
                out.push(')');
×
762
            }
300,000✔
763
        }
764
        AssertionExpr::Binary { op, left, right } => {
800,009✔
765
            if prec > 3 {
800,009✔
NEW
766
                out.push('(');
×
767
            }
800,009✔
768
            push_assertion(left, out, 3);
800,009✔
769
            out.push(' ');
800,009✔
770
            out.push_str(op.as_str());
800,009✔
771
            out.push(' ');
800,009✔
772
            push_assertion(right, out, 3);
800,009✔
773
            if prec > 3 {
800,009✔
NEW
774
                out.push(')');
×
775
            }
800,009✔
776
        }
777
        AssertionExpr::Not(inner) => {
1✔
778
            out.push('!');
1✔
779
            push_assertion(inner, out, 4);
1✔
780
        }
1✔
NEW
781
        AssertionExpr::NotNot(inner) => {
×
NEW
782
            out.push_str("not not ");
×
NEW
783
            push_assertion(inner, out, 4);
×
NEW
784
        }
×
785
        AssertionExpr::IfThenElse {
786
            condition,
100,005✔
787
            then_branch,
100,005✔
788
            else_branch,
100,005✔
789
        } => {
100,005✔
790
            out.push('(');
100,005✔
791
            push_assertion(condition, out, 0);
100,005✔
792
            out.push_str(" ? ");
100,005✔
793
            push_assertion(then_branch, out, 0);
100,005✔
794
            out.push_str(" : ");
100,005✔
795
            push_assertion(else_branch, out, 0);
100,005✔
796
            out.push(')');
100,005✔
797
        }
100,005✔
798
        AssertionExpr::Paren(inner) => {
200,000✔
799
            out.push('(');
200,000✔
800
            push_assertion(inner, out, 0);
200,000✔
801
            out.push(')');
200,000✔
802
        }
200,000✔
803
        AssertionExpr::Atom(e) => push_expr(e, out),
2,000,033✔
NEW
804
        AssertionExpr::Raw(s) => out.push_str(s),
×
805
    }
806
}
3,500,049✔
807

808
/// Remove redundant parentheses.
809
pub fn remove_redundant_parens(expr: &AssertionExpr) -> AssertionExpr {
939✔
810
    match expr {
939✔
811
        AssertionExpr::Paren(inner) => remove_redundant_parens(inner),
23✔
812
        AssertionExpr::Binary { op, left, right } => AssertionExpr::Binary {
197✔
813
            op: *op,
197✔
814
            left: Box::new(remove_redundant_parens(left)),
197✔
815
            right: Box::new(remove_redundant_parens(right)),
197✔
816
        },
197✔
817
        AssertionExpr::Not(e) => AssertionExpr::Not(Box::new(remove_redundant_parens(e))),
28✔
818
        AssertionExpr::NotNot(e) => AssertionExpr::NotNot(Box::new(remove_redundant_parens(e))),
18✔
819
        AssertionExpr::And { left, right } => AssertionExpr::And {
9✔
820
            left: Box::new(remove_redundant_parens(left)),
9✔
821
            right: Box::new(remove_redundant_parens(right)),
9✔
822
        },
9✔
823
        AssertionExpr::Or { left, right } => AssertionExpr::Or {
8✔
824
            left: Box::new(remove_redundant_parens(left)),
8✔
825
            right: Box::new(remove_redundant_parens(right)),
8✔
826
        },
8✔
827
        AssertionExpr::Xor { left, right } => AssertionExpr::Xor {
×
828
            left: Box::new(remove_redundant_parens(left)),
×
829
            right: Box::new(remove_redundant_parens(right)),
×
830
        },
×
831
        AssertionExpr::IfThenElse {
832
            condition,
32✔
833
            then_branch,
32✔
834
            else_branch,
32✔
835
        } => AssertionExpr::IfThenElse {
32✔
836
            condition: Box::new(remove_redundant_parens(condition)),
32✔
837
            then_branch: Box::new(remove_redundant_parens(then_branch)),
32✔
838
            else_branch: Box::new(remove_redundant_parens(else_branch)),
32✔
839
        },
32✔
840
        _ => expr.clone(),
624✔
841
    }
842
}
939✔
843

844
// ─── Tests ──────────────────────────────────────────────────────────────
845

846
#[cfg(test)]
847
mod tests {
848
    use super::*;
849
    use std::hint::black_box;
850
    use std::time::Instant;
851

852
    fn bench_phase(name: &str, iterations: u32, mut f: impl FnMut()) {
6✔
853
        let start = Instant::now();
6✔
854
        for _ in 0..iterations {
850,000✔
855
            f();
850,000✔
856
        }
850,000✔
857
        let elapsed = start.elapsed();
6✔
858
        let per_call = elapsed / iterations;
6✔
859
        eprintln!(
6✔
860
            "{}: {} iterations in {:?} ({:?}/call)",
861
            name, iterations, elapsed, per_call
862
        );
863
    }
6✔
864

865
    #[test]
866
    fn perf_phase_breakdown_simple() {
1✔
867
        let expr = ".id == 42 and .active == true";
1✔
868

869
        bench_phase("ast_phase_simple_tokenize", 200_000, || {
200,000✔
870
            let tokens = tokenize_assertion(black_box(expr));
200,000✔
871
            black_box(tokens.len());
200,000✔
872
        });
200,000✔
873

874
        bench_phase("ast_phase_simple_parse_from_tokens", 200_000, || {
200,000✔
875
            let tokens = tokenize_assertion(black_box(expr));
200,000✔
876
            let mut pos = 0;
200,000✔
877
            let ast = parse_pipe(&tokens, &mut pos);
200,000✔
878
            black_box(matches!(ast, AssertionExpr::Raw(_)));
200,000✔
879
            black_box(pos);
200,000✔
880
        });
200,000✔
881

882
        bench_phase("ast_phase_simple_serialize", 200_000, || {
200,000✔
883
            let ast = parse_assertion(black_box(expr));
200,000✔
884
            let s = assertion_to_string(&ast);
200,000✔
885
            black_box(s.len());
200,000✔
886
        });
200,000✔
887
    }
1✔
888

889
    #[test]
890
    fn perf_phase_breakdown_complex() {
1✔
891
        let expr = "if @len(.items) > 0 then (@regex(.name, /foo.*/i) and .meta.version >= 2) else (.status == \"empty\" or {{ feature_flag }} == true) end";
1✔
892

893
        bench_phase("ast_phase_complex_tokenize", 100_000, || {
100,000✔
894
            let tokens = tokenize_assertion(black_box(expr));
100,000✔
895
            black_box(tokens.len());
100,000✔
896
        });
100,000✔
897

898
        bench_phase("ast_phase_complex_parse_from_tokens", 100_000, || {
100,000✔
899
            let tokens = tokenize_assertion(black_box(expr));
100,000✔
900
            let mut pos = 0;
100,000✔
901
            let ast = parse_pipe(&tokens, &mut pos);
100,000✔
902
            black_box(matches!(ast, AssertionExpr::Raw(_)));
100,000✔
903
            black_box(pos);
100,000✔
904
        });
100,000✔
905

906
        bench_phase("ast_phase_complex_serialize", 50_000, || {
50,000✔
907
            let ast = parse_assertion(black_box(expr));
50,000✔
908
            let s = assertion_to_string(&ast);
50,000✔
909
            black_box(s.len());
50,000✔
910
        });
50,000✔
911
    }
1✔
912

913
    #[test]
914
    fn test_parse_simple_equality() {
1✔
915
        let expr = parse_assertion(".id == 123");
1✔
916
        if let AssertionExpr::Binary { op, left, right } = expr {
1✔
917
            assert_eq!(op, BinaryOp::Eq);
1✔
918
            assert!(matches!(*left, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
919
            assert!(matches!(
1✔
920
                *right,
1✔
921
                AssertionExpr::Atom(Expr::Literal(Literal::Number(_)))
922
            ));
923
        } else {
924
            panic!("Expected Binary, got: {:?}", expr);
×
925
        }
926
    }
1✔
927

928
    #[test]
929
    fn test_parse_plugin_call() {
1✔
930
        let expr = parse_assertion("@uuid(.user_id) == true");
1✔
931
        if let AssertionExpr::Binary { op, left, .. } = expr {
1✔
932
            assert_eq!(op, BinaryOp::Eq);
1✔
933
            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1✔
934
                assert_eq!(name, "uuid");
1✔
935
                assert_eq!(args.len(), 1);
1✔
936
            } else {
937
                panic!("Expected PluginCall");
×
938
            }
939
        } else {
940
            panic!("Expected Binary, got: {:?}", expr);
×
941
        }
942
    }
1✔
943

944
    #[test]
945
    fn test_parse_negation_bang() {
1✔
946
        let expr = parse_assertion("!@has_header(\"x\")");
1✔
947
        if let AssertionExpr::Not(inner) = expr {
1✔
948
            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1✔
949
                assert_eq!(name, "has_header");
1✔
950
            } else {
951
                panic!("Expected PluginCall inside Not");
×
952
            }
953
        } else {
954
            panic!("Expected Not, got: {:?}", expr);
×
955
        }
956
    }
1✔
957

958
    #[test]
959
    fn test_parse_pipe_not() {
1✔
960
        let expr = parse_assertion("@empty(.id) | not");
1✔
961
        if let AssertionExpr::Not(inner) = expr {
1✔
962
            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1✔
963
                assert_eq!(name, "empty");
1✔
964
            } else {
965
                panic!("Expected PluginCall inside Not, got: {:?}", inner);
×
966
            }
967
        } else {
968
            panic!("Expected Not, got: {:?}", expr);
×
969
        }
970
    }
1✔
971

972
    #[test]
973
    fn test_parse_pipe_not_not() {
1✔
974
        let expr = parse_assertion("@empty(.id) | not not");
1✔
975
        if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = expr {
1✔
976
            assert_eq!(name, "empty");
1✔
977
        } else {
978
            panic!(
×
979
                "Expected bare PluginCall (double negation cancels), got: {:?}",
980
                expr
981
            );
982
        }
983
    }
1✔
984

985
    #[test]
986
    fn test_parse_negation_not_keyword() {
1✔
987
        let expr = parse_assertion("not @empty(.id)");
1✔
988
        if let AssertionExpr::Not(inner) = expr {
1✔
989
            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1✔
990
                assert_eq!(name, "empty");
1✔
991
            } else {
992
                panic!("Expected PluginCall");
×
993
            }
994
        } else {
995
            panic!("Expected Not, got: {:?}", expr);
×
996
        }
997
    }
1✔
998

999
    #[test]
1000
    fn test_parse_xor() {
1✔
1001
        let expr = parse_assertion("@uuid(.id) xor @email(.name)");
1✔
1002
        if let AssertionExpr::Xor { left, right, .. } = expr {
1✔
1003
            assert!(matches!(
1✔
1004
                *left,
1✔
1005
                AssertionExpr::Atom(Expr::PluginCall { .. })
1006
            ));
1007
            assert!(matches!(
1✔
1008
                *right,
1✔
1009
                AssertionExpr::Atom(Expr::PluginCall { .. })
1010
            ));
1011
        } else {
1012
            panic!("Expected Xor, got: {:?}", expr);
×
1013
        }
1014
    }
1✔
1015

1016
    #[test]
1017
    fn test_parse_or() {
1✔
1018
        let expr = parse_assertion("@uuid(.id) or @email(.name)");
1✔
1019
        assert!(
1✔
1020
            matches!(expr, AssertionExpr::Or { .. }),
1✔
1021
            "Expected Or, got: {:?}",
1022
            expr
1023
        );
1024
    }
1✔
1025

1026
    #[test]
1027
    fn test_parse_and_or_xor_precedence() {
1✔
1028
        let expr = parse_assertion("@a or @b xor @c and @d");
1✔
1029
        assert!(
1✔
1030
            matches!(expr, AssertionExpr::Or { .. }),
1✔
1031
            "Expected Or, got: {:?}",
1032
            expr
1033
        );
1034
    }
1✔
1035

1036
    #[test]
1037
    fn test_parse_paren_or_in_and() {
1✔
1038
        let expr = parse_assertion("(@a or @b) and @c");
1✔
1039
        if let AssertionExpr::And { left, .. } = expr {
1✔
1040
            assert!(
1✔
1041
                matches!(*left, AssertionExpr::Paren(_)),
1✔
1042
                "Left should be Paren(Or)"
1043
            );
1044
        } else {
1045
            panic!("Expected And, got: {:?}", expr);
×
1046
        }
1047
    }
1✔
1048

1049
    #[test]
1050
    fn test_parse_negated_paren_or() {
1✔
1051
        let expr = parse_assertion("!(@empty(.id) or @uuid(.id))");
1✔
1052
        if let AssertionExpr::Not(inner) = expr {
1✔
1053
            if let AssertionExpr::Paren(or_expr) = &*inner {
1✔
1054
                assert!(matches!(**or_expr, AssertionExpr::Or { .. }));
1✔
1055
            } else {
1056
                panic!("Expected Paren(Or), got: {:?}", inner);
×
1057
            }
1058
        } else {
1059
            panic!("Expected Not, got: {:?}", expr);
×
1060
        }
1061
    }
1✔
1062

1063
    #[test]
1064
    fn test_roundtrip_xor() {
1✔
1065
        let expr = parse_assertion("@a() xor @b()");
1✔
1066
        let s = assertion_to_string(&expr);
1✔
1067
        assert!(s.contains(" xor "), "Should contain xor: {}", s);
1✔
1068
    }
1✔
1069

1070
    #[test]
1071
    fn test_roundtrip_pipe_not() {
1✔
1072
        let expr = parse_assertion("@empty(.id) | not");
1✔
1073
        let s = assertion_to_string(&expr);
1✔
1074
        assert!(s.contains('!'), "Pipe not should serialize as !: {}", s);
1✔
1075
    }
1✔
1076

1077
    #[test]
1078
    fn test_contains() {
1✔
1079
        let expr = parse_assertion(".name contains \"test\"");
1✔
1080
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
1081
            assert_eq!(op, BinaryOp::Contains);
1✔
1082
        } else {
1083
            panic!("Expected Binary");
×
1084
        }
1085
    }
1✔
1086

1087
    #[test]
1088
    fn test_startswith() {
1✔
1089
        let expr = parse_assertion(".name startsWith \"te\"");
1✔
1090
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
1091
            assert_eq!(op, BinaryOp::StartsWith);
1✔
1092
        } else {
1093
            panic!("Expected Binary");
×
1094
        }
1095
    }
1✔
1096

1097
    #[test]
1098
    fn test_matches() {
1✔
1099
        let expr = parse_assertion(".name matches \"^te.*t$\"");
1✔
1100
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
1101
            assert_eq!(op, BinaryOp::Matches);
1✔
1102
        } else {
1103
            panic!("Expected Binary");
×
1104
        }
1105
    }
1✔
1106

1107
    #[test]
1108
    fn test_if_then_else() {
1✔
1109
        let expr = parse_assertion("if @len(.items) == 0 then true else false end");
1✔
1110
        if let AssertionExpr::IfThenElse {
1111
            condition,
1✔
1112
            then_branch,
1✔
1113
            else_branch,
1✔
1114
        } = expr
1✔
1115
        {
1116
            assert!(matches!(*condition, AssertionExpr::Binary { .. }));
1✔
1117
            assert!(matches!(
1✔
1118
                *then_branch,
1✔
1119
                AssertionExpr::Atom(Expr::Literal(Literal::Bool(true)))
1120
            ));
1121
            assert!(matches!(
1✔
1122
                *else_branch,
1✔
1123
                AssertionExpr::Atom(Expr::Literal(Literal::Bool(false)))
1124
            ));
1125
        } else {
1126
            panic!("Expected IfThenElse");
×
1127
        }
1128
    }
1✔
1129

1130
    #[test]
1131
    fn test_if_then_else_serializes_as_ternary() {
1✔
1132
        let expr = parse_assertion("if .x == 0 then true else false end");
1✔
1133
        let s = assertion_to_string(&expr);
1✔
1134
        assert!(s.contains('?'), "Should contain ternary: {}", s);
1✔
1135
        assert!(s.contains(':'), "Should contain colon: {}", s);
1✔
1136
    }
1✔
1137

1138
    #[test]
1139
    fn test_nested_if_serializes_correctly() {
1✔
1140
        let expr =
1✔
1141
            parse_assertion("if .a == 1 then if .b == 2 then \"A\" else \"B\" end else \"C\" end");
1✔
1142
        if let AssertionExpr::IfThenElse {
1143
            then_branch,
1✔
1144
            else_branch,
1✔
1145
            ..
1146
        } = &expr
1✔
1147
        {
1148
            assert!(matches!(**then_branch, AssertionExpr::IfThenElse { .. }));
1✔
1149
            assert!(matches!(
1✔
1150
                **else_branch,
1✔
1151
                AssertionExpr::Atom(Expr::Literal(Literal::Str(_)))
1152
            ));
1153
        } else {
1154
            panic!("Expected IfThenElse");
×
1155
        }
1156
        let s = assertion_to_string(&expr);
1✔
1157
        assert!(s.contains('('), "Nested ternary should have parens: {}", s);
1✔
1158
    }
1✔
1159

1160
    #[test]
1161
    fn test_remove_redundant_parens() {
1✔
1162
        let expr = parse_assertion("((.x == 1))");
1✔
1163
        let simplified = remove_redundant_parens(&expr);
1✔
1164
        let s = assertion_to_string(&simplified);
1✔
1165
        assert!(!s.starts_with("(("), "Should not have double parens: {}", s);
1✔
1166
    }
1✔
1167

1168
    #[test]
1169
    fn test_roundtrip_simple() {
1✔
1170
        let original = ".id == 123";
1✔
1171
        let expr = parse_assertion(original);
1✔
1172
        assert_eq!(assertion_to_string(&expr), original);
1✔
1173
    }
1✔
1174

1175
    #[test]
1176
    fn test_roundtrip_with_plugin() {
1✔
1177
        let original = "@len(.items) == 0";
1✔
1178
        let expr = parse_assertion(original);
1✔
1179
        assert_eq!(assertion_to_string(&expr), original);
1✔
1180
    }
1✔
1181

1182
    #[test]
1183
    fn test_parse_and_or() {
1✔
1184
        assert!(matches!(
1✔
1185
            parse_assertion(".x == 1 and .y == 2"),
1✔
1186
            AssertionExpr::And { .. }
1187
        ));
1188
        assert!(matches!(
1✔
1189
            parse_assertion(".x == 1 or .y == 2"),
1✔
1190
            AssertionExpr::Or { .. }
1191
        ));
1192
    }
1✔
1193

1194
    #[test]
1195
    fn test_parse_not_not() {
1✔
1196
        if let AssertionExpr::NotNot(inner) = parse_assertion("not not .x") {
1✔
1197
            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
1198
        } else {
1199
            panic!("Expected NotNot");
×
1200
        }
1201
    }
1✔
1202

1203
    #[test]
1204
    fn test_parse_double_bang() {
1✔
1205
        if let AssertionExpr::NotNot(inner) = parse_assertion("!!.x") {
1✔
1206
            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
1207
        } else {
1208
            panic!("Expected NotNot");
×
1209
        }
1210
    }
1✔
1211

1212
    #[test]
1213
    fn test_parse_regex_literal() {
1✔
1214
        let expr = parse_assertion("@regex(.name, /^hello/i) == true");
1✔
1215
        if let AssertionExpr::Binary { op, left, .. } = expr {
1✔
1216
            assert_eq!(op, BinaryOp::Eq);
1✔
1217
            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1✔
1218
                assert_eq!(name, "regex");
1✔
1219
                assert_eq!(args.len(), 2);
1✔
1220
                if let AssertionExpr::Atom(a) = &args[1] {
1✔
1221
                    if let Expr::RegExp { pattern, flags } = &a {
1✔
1222
                        assert_eq!(pattern, "^hello");
1✔
1223
                        assert_eq!(flags, "");
1✔
1224
                    } else {
1225
                        panic!("Expected RegExp");
×
1226
                    }
1227
                } else {
1228
                    panic!("Expected Atom");
×
1229
                }
1230
            } else {
1231
                panic!("Expected PluginCall");
×
1232
            }
1233
        } else {
1234
            panic!("Expected Binary");
×
1235
        }
1236
    }
1✔
1237

1238
    #[test]
1239
    fn test_parse_regex_serializes_correctly() {
1✔
1240
        let expr = parse_assertion("@regex(.x, /\\d{4}/gi) == true");
1✔
1241
        let s = assertion_to_string(&expr);
1✔
1242
        assert!(s.contains("/\\d{4}/"), "Should contain regex: {}", s);
1✔
1243
    }
1✔
1244

1245
    #[test]
1246
    fn test_parse_json_literal() {
1✔
1247
        let expr = parse_assertion("@json(.data) == {\"key\": \"value\"}");
1✔
1248
        if let AssertionExpr::Binary { op, right, .. } = expr {
1✔
1249
            assert_eq!(op, BinaryOp::Eq);
1✔
1250
            if let AssertionExpr::Atom(Expr::Json(s)) = &*right {
1✔
1251
                assert!(s.contains("\"key\""));
1✔
1252
                assert!(s.contains("\"value\""));
1✔
1253
            } else {
1254
                panic!("Expected Json");
×
1255
            }
1256
        } else {
1257
            panic!("Expected Binary");
×
1258
        }
1259
    }
1✔
1260

1261
    #[test]
1262
    fn test_nested_ternary_roundtrip() {
1✔
1263
        let original = "if .x == 1 then if .y == 2 then true else false end else false end";
1✔
1264
        let expr = parse_assertion(original);
1✔
1265
        let s = assertion_to_string(&expr);
1✔
1266
        assert!(s.contains('?'), "Should contain ternary: {}", s);
1✔
1267
        assert!(s.contains('('), "Should contain parens: {}", s);
1✔
1268
    }
1✔
1269
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc