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

gripmock / grpctestify-rust / 24369349915

13 Apr 2026 10:06PM UTC coverage: 76.457% (+1.0%) from 75.445%
24369349915

Pull #35

github

web-flow
Merge c1fe8cdb1 into 4ba0f08f1
Pull Request #35: feat: meta section & refactoring

2929 of 3902 new or added lines in 48 files covered. (75.06%)

155 existing lines in 9 files now uncovered.

17309 of 22639 relevant lines covered (76.46%)

2463.13 hits per line

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

68.11
/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
use serde::{Deserialize, Serialize};
8

9
use super::tokenizer::{TokenKind, tokenize_assertion};
10

11
/// A complete assertion expression (top-level).
12
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13
pub enum AssertionExpr {
14
    Binary {
15
        op: BinaryOp,
16
        left: Box<AssertionExpr>,
17
        right: Box<AssertionExpr>,
18
    },
19
    Not(Box<AssertionExpr>),
20
    NotNot(Box<AssertionExpr>),
21
    And {
22
        left: Box<AssertionExpr>,
23
        right: Box<AssertionExpr>,
24
    },
25
    Or {
26
        left: Box<AssertionExpr>,
27
        right: Box<AssertionExpr>,
28
    },
29
    IfThenElse {
30
        condition: Box<AssertionExpr>,
31
        then_branch: Box<AssertionExpr>,
32
        else_branch: Box<AssertionExpr>,
33
    },
34
    Paren(Box<AssertionExpr>),
35
    Atom(Expr),
36
    Raw(String),
37
}
38

39
/// Atomic expressions (leaf nodes).
40
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41
pub enum Expr {
42
    JqPath(String),
43
    PluginCall {
44
        name: String,
45
        args: Vec<AssertionExpr>,
46
    },
47
    Literal(Literal),
48
    Variable(String),
49
    RegExp {
50
        pattern: String,
51
        flags: String,
52
    },
53
    Json(String),
54
    Yaml(String),
55
}
56

57
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58
pub enum Literal {
59
    Bool(bool),
60
    Number(String),
61
    Str(String),
62
    Null,
63
}
64

65
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66
pub enum BinaryOp {
67
    Eq,
68
    Ne,
69
    Gt,
70
    Lt,
71
    Ge,
72
    Le,
73
    Contains,
74
    Matches,
75
    StartsWith,
76
    EndsWith,
77
}
78

79
impl BinaryOp {
80
    pub fn as_str(&self) -> &'static str {
9✔
81
        match self {
9✔
82
            Self::Eq => "==",
9✔
NEW
83
            Self::Ne => "!=",
×
NEW
84
            Self::Gt => ">",
×
NEW
85
            Self::Lt => "<",
×
NEW
86
            Self::Ge => ">=",
×
NEW
87
            Self::Le => "<=",
×
NEW
88
            Self::Contains => "contains",
×
NEW
89
            Self::Matches => "matches",
×
NEW
90
            Self::StartsWith => "startsWith",
×
NEW
91
            Self::EndsWith => "endsWith",
×
92
        }
93
    }
9✔
94
    fn try_parse(s: &str) -> Option<Self> {
21✔
95
        match s {
21✔
96
            "==" => Some(Self::Eq),
21✔
97
            "!=" => Some(Self::Ne),
3✔
98
            ">" => Some(Self::Gt),
3✔
99
            "<" => Some(Self::Lt),
3✔
100
            ">=" => Some(Self::Ge),
3✔
101
            "<=" => Some(Self::Le),
3✔
102
            "contains" => Some(Self::Contains),
3✔
103
            "matches" => Some(Self::Matches),
2✔
104
            "startsWith" | "startswith" => Some(Self::StartsWith),
1✔
NEW
105
            "endsWith" | "endswith" => Some(Self::EndsWith),
×
NEW
106
            _ => None,
×
107
        }
108
    }
21✔
109
}
110

111
// ─── Display ───────────────────────────────────────────────────────────
112

113
impl std::fmt::Display for Expr {
114
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29✔
115
        match self {
17✔
116
            Self::JqPath(p) => write!(f, "{}", p),
9✔
117
            Self::PluginCall { name, args } => {
2✔
118
                write!(f, "@{}(", name)?;
2✔
119
                for (i, a) in args.iter().enumerate() {
3✔
120
                    if i > 0 {
3✔
121
                        write!(f, ", ")?;
1✔
122
                    }
2✔
123
                    write!(f, "{}", a)?;
3✔
124
                }
125
                write!(f, ")")
2✔
126
            }
127
            Self::Literal(Literal::Bool(b)) => write!(f, "{}", b),
6✔
128
            Self::Literal(Literal::Number(n)) => write!(f, "{}", n),
8✔
129
            Self::Literal(Literal::Str(s)) => write!(f, "\"{}\"", s),
3✔
NEW
130
            Self::Literal(Literal::Null) => write!(f, "null"),
×
131
            Self::RegExp { pattern, flags } => {
1✔
132
                write!(f, "/{}/", pattern)?;
1✔
133
                if !flags.is_empty() {
1✔
NEW
134
                    write!(f, "{}", flags)?;
×
135
                }
1✔
136
                Ok(())
1✔
137
            }
NEW
138
            Self::Json(s) => write!(f, "{}", s),
×
NEW
139
            Self::Yaml(s) => write!(f, "{}", s),
×
NEW
140
            Self::Variable(n) => write!(f, "{{{{{}}}}}", n),
×
141
        }
142
    }
29✔
143
}
144

145
impl std::fmt::Display for AssertionExpr {
146
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10✔
147
        fmt_assertion(self, f, 0)
10✔
148
    }
10✔
149
}
150

151
fn fmt_assertion(e: &AssertionExpr, f: &mut std::fmt::Formatter<'_>, prec: u8) -> std::fmt::Result {
43✔
152
    match e {
43✔
NEW
153
        AssertionExpr::Or { left, right } => {
×
NEW
154
            if prec > 1 {
×
NEW
155
                write!(f, "(")?;
×
NEW
156
            }
×
NEW
157
            fmt_assertion(left, f, 1)?;
×
NEW
158
            write!(f, " or ")?;
×
NEW
159
            fmt_assertion(right, f, 1)?;
×
NEW
160
            if prec > 1 {
×
NEW
161
                write!(f, ")")?;
×
NEW
162
            }
×
NEW
163
            Ok(())
×
164
        }
NEW
165
        AssertionExpr::And { left, right } => {
×
NEW
166
            if prec > 2 {
×
NEW
167
                write!(f, "(")?;
×
NEW
168
            }
×
NEW
169
            fmt_assertion(left, f, 2)?;
×
NEW
170
            write!(f, " and ")?;
×
NEW
171
            fmt_assertion(right, f, 2)?;
×
NEW
172
            if prec > 2 {
×
NEW
173
                write!(f, ")")?;
×
NEW
174
            }
×
NEW
175
            Ok(())
×
176
        }
177
        AssertionExpr::Binary { op, left, right } => {
9✔
178
            if prec > 3 {
9✔
NEW
179
                write!(f, "(")?;
×
180
            }
9✔
181
            fmt_assertion(left, f, 3)?;
9✔
182
            write!(f, " {} ", op.as_str())?;
9✔
183
            fmt_assertion(right, f, 3)?;
9✔
184
            if prec > 3 {
9✔
NEW
185
                write!(f, ")")?;
×
186
            }
9✔
187
            Ok(())
9✔
188
        }
NEW
189
        AssertionExpr::Not(inner) => {
×
NEW
190
            write!(f, "!")?;
×
NEW
191
            fmt_assertion(inner, f, 4)
×
192
        }
NEW
193
        AssertionExpr::NotNot(inner) => {
×
NEW
194
            write!(f, "not not ")?;
×
NEW
195
            fmt_assertion(inner, f, 4)
×
196
        }
197
        AssertionExpr::IfThenElse {
198
            condition,
5✔
199
            then_branch,
5✔
200
            else_branch,
5✔
201
        } => {
202
            write!(f, "(")?;
5✔
203
            fmt_assertion(condition, f, 0)?;
5✔
204
            write!(f, " ? ")?;
5✔
205
            fmt_assertion(then_branch, f, 0)?;
5✔
206
            write!(f, " : ")?;
5✔
207
            fmt_assertion(else_branch, f, 0)?;
5✔
208
            write!(f, ")")
5✔
209
        }
NEW
210
        AssertionExpr::Paren(inner) => {
×
NEW
211
            write!(f, "(")?;
×
NEW
212
            fmt_assertion(inner, f, 0)?;
×
NEW
213
            write!(f, ")")
×
214
        }
215
        AssertionExpr::Atom(e) => write!(f, "{}", e),
29✔
NEW
216
        AssertionExpr::Raw(s) => write!(f, "{}", s),
×
217
    }
218
}
43✔
219

220
// ─── Parser ────────────────────────────────────────────────────────────
221
// Uses the public tokenizer. Pipeline: text → tokens → AST.
222

223
/// Parse a raw assertion string into an AST.
224
/// Falls back to `Raw` if parsing fails.
225
pub fn parse_assertion(raw: &str) -> AssertionExpr {
20✔
226
    let tokens = tokenize_assertion(raw);
20✔
227
    if tokens.is_empty() {
20✔
NEW
228
        return AssertionExpr::Raw(raw.to_string());
×
229
    }
20✔
230
    let mut pos = 0;
20✔
231
    let expr = parse_or(&tokens, &mut pos);
20✔
232
    if pos >= tokens.len() {
20✔
233
        expr
20✔
234
    } else {
NEW
235
        AssertionExpr::Raw(raw.to_string())
×
236
    }
237
}
20✔
238

239
fn parse_or(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
49✔
240
    let mut left = parse_and(ts, p);
49✔
241
    while *p < ts.len() && is_keyword(ts, *p, "or") {
50✔
242
        *p += 1;
1✔
243
        let right = parse_and(ts, p);
1✔
244
        left = AssertionExpr::Or {
1✔
245
            left: Box::new(left),
1✔
246
            right: Box::new(right),
1✔
247
        };
1✔
248
    }
1✔
249
    left
49✔
250
}
49✔
251

252
fn parse_and(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
50✔
253
    let mut left = parse_bin(ts, p);
50✔
254
    while *p < ts.len() && is_keyword(ts, *p, "and") {
51✔
255
        *p += 1;
1✔
256
        let right = parse_bin(ts, p);
1✔
257
        left = AssertionExpr::And {
1✔
258
            left: Box::new(left),
1✔
259
            right: Box::new(right),
1✔
260
        };
1✔
261
    }
1✔
262
    left
50✔
263
}
50✔
264

265
fn parse_bin(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
51✔
266
    let mut left = parse_unary(ts, p);
51✔
267
    loop {
268
        let op_info = if *p < ts.len() {
72✔
269
            match &ts[*p].kind {
52✔
270
                TokenKind::Op(s) => Some(s.clone()),
21✔
271
                TokenKind::Ident(s) if is_bin_op_keyword(s) => Some(s.clone()),
20✔
272
                _ => None,
31✔
273
            }
274
        } else {
275
            None
20✔
276
        };
277

278
        let op_str = match op_info {
72✔
279
            Some(s) => s,
21✔
280
            None => break,
51✔
281
        };
282
        *p += 1;
21✔
283
        let right = parse_unary(ts, p);
21✔
284
        let op = BinaryOp::try_parse(&op_str).unwrap_or(match op_str.as_str() {
21✔
285
            "contains" => BinaryOp::Contains,
21✔
286
            "matches" => BinaryOp::Matches,
20✔
287
            "startsWith" | "startswith" => BinaryOp::StartsWith,
19✔
288
            _ => BinaryOp::EndsWith,
18✔
289
        });
290
        left = AssertionExpr::Binary {
21✔
291
            op,
21✔
292
            left: Box::new(left),
21✔
293
            right: Box::new(right),
21✔
294
        };
21✔
295
    }
296
    left
51✔
297
}
51✔
298

299
fn parse_unary(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
72✔
300
    if *p >= ts.len() {
72✔
NEW
301
        return AssertionExpr::Raw(String::new());
×
302
    }
72✔
303
    match &ts[*p].kind {
72✔
304
        TokenKind::Bang => {
305
            *p += 1;
2✔
306
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Bang) {
2✔
307
                *p += 1;
1✔
308
                AssertionExpr::NotNot(Box::new(parse_atom(ts, p)))
1✔
309
            } else {
310
                AssertionExpr::Not(Box::new(parse_atom(ts, p)))
1✔
311
            }
312
        }
313
        TokenKind::Ident(s) if s == "not" => {
17✔
314
            *p += 1;
1✔
315
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Ident(ref s2) if s2 == "not") {
1✔
316
                *p += 1;
1✔
317
                AssertionExpr::NotNot(Box::new(parse_atom(ts, p)))
1✔
318
            } else {
NEW
319
                AssertionExpr::Not(Box::new(parse_atom(ts, p)))
×
320
            }
321
        }
322
        TokenKind::Ident(s) if s == "if" => parse_if(ts, p),
16✔
323
        TokenKind::LParen => {
324
            *p += 1;
2✔
325
            let inner = parse_or(ts, p);
2✔
326
            if *p < ts.len() && matches!(ts[*p].kind, TokenKind::RParen) {
2✔
327
                *p += 1;
2✔
328
            }
2✔
329
            AssertionExpr::Paren(Box::new(inner))
2✔
330
        }
331
        _ => parse_atom(ts, p),
61✔
332
    }
333
}
72✔
334

335
fn parse_if(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
6✔
336
    *p += 1;
6✔
337
    let cond = parse_or(ts, p);
6✔
338
    if *p >= ts.len() || !is_keyword(ts, *p, "then") {
6✔
NEW
339
        return AssertionExpr::Raw("if..then missing".into());
×
340
    }
6✔
341
    *p += 1;
6✔
342
    let then_b = parse_or(ts, p);
6✔
343
    if *p >= ts.len() || !is_keyword(ts, *p, "else") {
6✔
NEW
344
        return AssertionExpr::Raw("if..else missing".into());
×
345
    }
6✔
346
    *p += 1;
6✔
347
    let else_b = parse_or(ts, p);
6✔
348
    if *p < ts.len() && is_keyword(ts, *p, "end") {
6✔
349
        *p += 1;
6✔
350
    }
6✔
351
    AssertionExpr::IfThenElse {
6✔
352
        condition: Box::new(cond),
6✔
353
        then_branch: Box::new(then_b),
6✔
354
        else_branch: Box::new(else_b),
6✔
355
    }
6✔
356
}
6✔
357

358
fn parse_atom(ts: &[super::tokenizer::Token], p: &mut usize) -> AssertionExpr {
64✔
359
    if *p >= ts.len() {
64✔
NEW
360
        return AssertionExpr::Atom(Expr::JqPath(String::new()));
×
361
    }
64✔
362
    let expr = match &ts[*p].kind {
64✔
363
        TokenKind::StringLit(s) => {
7✔
364
            *p += 1;
7✔
365
            Expr::Literal(Literal::Str(s.clone()))
7✔
366
        }
367
        TokenKind::NumberLit(n) => {
14✔
368
            *p += 1;
14✔
369
            Expr::Literal(Literal::Number(n.clone()))
14✔
370
        }
371
        TokenKind::Ident(s) if s == "true" => {
10✔
372
            *p += 1;
6✔
373
            Expr::Literal(Literal::Bool(true))
6✔
374
        }
375
        TokenKind::Ident(s) if s == "false" => {
4✔
376
            *p += 1;
4✔
377
            Expr::Literal(Literal::Bool(false))
4✔
378
        }
NEW
379
        TokenKind::Ident(s) if s == "null" => {
×
NEW
380
            *p += 1;
×
NEW
381
            Expr::Literal(Literal::Null)
×
382
        }
383
        TokenKind::VarDelim => {
NEW
384
            *p += 1;
×
NEW
385
            let mut name = String::new();
×
NEW
386
            while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::VarDelim) {
×
NEW
387
                if let TokenKind::Ident(s) = &ts[*p].kind {
×
NEW
388
                    if !name.is_empty() {
×
NEW
389
                        name.push(' ');
×
NEW
390
                    }
×
NEW
391
                    name.push_str(s);
×
NEW
392
                }
×
NEW
393
                *p += 1;
×
394
            }
NEW
395
            if *p < ts.len() {
×
NEW
396
                *p += 1;
×
NEW
397
            }
×
NEW
398
            Expr::Variable(name)
×
399
        }
400
        TokenKind::At => {
401
            *p += 1;
7✔
402
            let name = if *p < ts.len() {
7✔
403
                if let TokenKind::Ident(s) = &ts[*p].kind {
7✔
404
                    *p += 1;
7✔
405
                    s.clone()
7✔
406
                } else {
NEW
407
                    String::new()
×
408
                }
409
            } else {
NEW
410
                String::new()
×
411
            };
412
            let args = if *p < ts.len() && matches!(ts[*p].kind, TokenKind::LParen) {
7✔
413
                *p += 1;
7✔
414
                let mut args = Vec::new();
7✔
415
                while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RParen) {
16✔
416
                    args.push(parse_or(ts, p));
9✔
417
                    if *p < ts.len() && matches!(ts[*p].kind, TokenKind::Comma) {
9✔
418
                        *p += 1;
2✔
419
                    }
7✔
420
                }
421
                if *p < ts.len() {
7✔
422
                    *p += 1;
7✔
423
                }
7✔
424
                args
7✔
425
            } else {
NEW
426
                Vec::new()
×
427
            };
428
            Expr::PluginCall { name, args }
7✔
429
        }
430
        TokenKind::RegExpLit { pattern, flags } => {
2✔
431
            *p += 1;
2✔
432
            Expr::RegExp {
2✔
433
                pattern: pattern.clone(),
2✔
434
                flags: flags.clone(),
2✔
435
            }
2✔
436
        }
437
        TokenKind::LBrace => {
438
            *p += 1;
1✔
439
            let mut json = String::from("{");
1✔
440
            let mut depth = 1;
1✔
441
            while *p < ts.len() && depth > 0 {
4✔
442
                match &ts[*p].kind {
3✔
NEW
443
                    TokenKind::LBrace => {
×
NEW
444
                        depth += 1;
×
NEW
445
                        json.push('{');
×
NEW
446
                        *p += 1;
×
NEW
447
                    }
×
448
                    TokenKind::RBrace => {
449
                        depth -= 1;
1✔
450
                        if depth > 0 {
1✔
NEW
451
                            json.push('}');
×
452
                        }
1✔
453
                        *p += 1;
1✔
454
                    }
NEW
455
                    TokenKind::LParen => {
×
NEW
456
                        json.push('(');
×
NEW
457
                        *p += 1;
×
NEW
458
                    }
×
NEW
459
                    TokenKind::RParen => {
×
NEW
460
                        json.push(')');
×
NEW
461
                        *p += 1;
×
NEW
462
                    }
×
NEW
463
                    TokenKind::LBracket => {
×
NEW
464
                        json.push('[');
×
NEW
465
                        *p += 1;
×
NEW
466
                    }
×
NEW
467
                    TokenKind::RBracket => {
×
NEW
468
                        json.push(']');
×
NEW
469
                        *p += 1;
×
NEW
470
                    }
×
471
                    TokenKind::StringLit(s) => {
2✔
472
                        json.push('"');
2✔
473
                        json.push_str(s);
2✔
474
                        json.push('"');
2✔
475
                        *p += 1;
2✔
476
                    }
2✔
NEW
477
                    TokenKind::NumberLit(n) => {
×
NEW
478
                        json.push_str(n);
×
NEW
479
                        *p += 1;
×
NEW
480
                    }
×
NEW
481
                    TokenKind::Ident(s) => {
×
NEW
482
                        json.push_str(s);
×
NEW
483
                        *p += 1;
×
NEW
484
                    }
×
NEW
485
                    TokenKind::Comma => {
×
NEW
486
                        json.push(',');
×
NEW
487
                        *p += 1;
×
NEW
488
                    }
×
NEW
489
                    TokenKind::Op(s) => {
×
NEW
490
                        json.push_str(s);
×
NEW
491
                        *p += 1;
×
NEW
492
                    }
×
NEW
493
                    TokenKind::Dot => {
×
NEW
494
                        json.push('.');
×
NEW
495
                        *p += 1;
×
NEW
496
                    }
×
NEW
497
                    TokenKind::At => {
×
NEW
498
                        json.push('@');
×
NEW
499
                        *p += 1;
×
NEW
500
                    }
×
NEW
501
                    TokenKind::VarDelim => {
×
NEW
502
                        json.push_str("{{ }}");
×
NEW
503
                        *p += 1;
×
NEW
504
                    }
×
NEW
505
                    _ => {
×
NEW
506
                        *p += 1;
×
NEW
507
                    }
×
508
                }
509
            }
510
            json.push('}');
1✔
511
            Expr::Json(json)
1✔
512
        }
513
        TokenKind::Dot | TokenKind::Ident(_) => {
514
            let mut path = String::new();
23✔
515
            while *p < ts.len() {
69✔
516
                if let TokenKind::Ident(s) = &ts[*p].kind
67✔
517
                    && (is_bin_op_keyword(s) || is_keyword_token(&ts[*p].kind))
23✔
518
                {
NEW
519
                    break;
×
520
                }
67✔
521
                match &ts[*p].kind {
67✔
522
                    TokenKind::Dot => {
23✔
523
                        path.push('.');
23✔
524
                        *p += 1;
23✔
525
                    }
23✔
526
                    TokenKind::Ident(s) => {
23✔
527
                        path.push_str(s);
23✔
528
                        *p += 1;
23✔
529
                    }
23✔
530
                    TokenKind::LBracket => {
NEW
531
                        path.push('[');
×
NEW
532
                        *p += 1;
×
NEW
533
                        while *p < ts.len() && !matches!(ts[*p].kind, TokenKind::RBracket) {
×
NEW
534
                            if let TokenKind::NumberLit(n) = &ts[*p].kind {
×
NEW
535
                                path.push_str(n);
×
NEW
536
                            } else if let TokenKind::Ident(s) = &ts[*p].kind {
×
NEW
537
                                path.push_str(s);
×
NEW
538
                            }
×
NEW
539
                            *p += 1;
×
540
                        }
NEW
541
                        if *p < ts.len() {
×
NEW
542
                            path.push(']');
×
NEW
543
                            *p += 1;
×
NEW
544
                        }
×
545
                    }
546
                    _ => break,
21✔
547
                }
548
            }
549
            Expr::JqPath(path)
23✔
550
        }
551
        _ => {
NEW
552
            *p += 1;
×
NEW
553
            Expr::JqPath(String::new())
×
554
        }
555
    };
556
    AssertionExpr::Atom(expr)
64✔
557
}
64✔
558

559
fn is_keyword(ts: &[super::tokenizer::Token], idx: usize, kw: &str) -> bool {
79✔
560
    matches!(ts.get(idx), Some(t) if matches!(&t.kind, TokenKind::Ident(s) if s == kw))
79✔
561
}
79✔
562

563
fn is_bin_op_keyword(s: &str) -> bool {
43✔
NEW
564
    matches!(
×
565
        s,
43✔
566
        "contains" | "matches" | "startsWith" | "endsWith" | "startswith" | "endswith"
43✔
567
    )
568
}
43✔
569

570
fn is_keyword_token(k: &TokenKind) -> bool {
23✔
571
    matches!(k, TokenKind::Ident(s) if matches!(s.as_str(), "and"|"or"|"contains"|"matches"|"startsWith"|"endsWith"|"startswith"|"endswith"))
23✔
572
}
23✔
573

574
// ─── Public helpers ─────────────────────────────────────────────────────
575

576
/// Convert AssertionExpr back to string (ternary for if-then-else).
577
pub fn assertion_to_string(expr: &AssertionExpr) -> String {
7✔
578
    expr.to_string()
7✔
579
}
7✔
580

581
/// Remove redundant parentheses.
582
pub fn remove_redundant_parens(expr: &AssertionExpr) -> AssertionExpr {
5✔
583
    match expr {
5✔
584
        AssertionExpr::Paren(inner) => remove_redundant_parens(inner),
2✔
585
        AssertionExpr::Binary { op, left, right } => AssertionExpr::Binary {
1✔
586
            op: *op,
1✔
587
            left: Box::new(remove_redundant_parens(left)),
1✔
588
            right: Box::new(remove_redundant_parens(right)),
1✔
589
        },
1✔
NEW
590
        AssertionExpr::Not(e) => AssertionExpr::Not(Box::new(remove_redundant_parens(e))),
×
NEW
591
        AssertionExpr::NotNot(e) => AssertionExpr::NotNot(Box::new(remove_redundant_parens(e))),
×
NEW
592
        AssertionExpr::And { left, right } => AssertionExpr::And {
×
NEW
593
            left: Box::new(remove_redundant_parens(left)),
×
NEW
594
            right: Box::new(remove_redundant_parens(right)),
×
NEW
595
        },
×
NEW
596
        AssertionExpr::Or { left, right } => AssertionExpr::Or {
×
NEW
597
            left: Box::new(remove_redundant_parens(left)),
×
NEW
598
            right: Box::new(remove_redundant_parens(right)),
×
NEW
599
        },
×
600
        AssertionExpr::IfThenElse {
NEW
601
            condition,
×
NEW
602
            then_branch,
×
NEW
603
            else_branch,
×
NEW
604
        } => AssertionExpr::IfThenElse {
×
NEW
605
            condition: Box::new(remove_redundant_parens(condition)),
×
NEW
606
            then_branch: Box::new(remove_redundant_parens(then_branch)),
×
NEW
607
            else_branch: Box::new(remove_redundant_parens(else_branch)),
×
NEW
608
        },
×
609
        _ => expr.clone(),
2✔
610
    }
611
}
5✔
612

613
// ─── Tests ──────────────────────────────────────────────────────────────
614

615
#[cfg(test)]
616
mod tests {
617
    use super::*;
618

619
    #[test]
620
    fn test_parse_simple_equality() {
1✔
621
        let expr = parse_assertion(".id == 123");
1✔
622
        if let AssertionExpr::Binary { op, left, right } = expr {
1✔
623
            assert_eq!(op, BinaryOp::Eq);
1✔
624
            assert!(matches!(*left, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
625
            assert!(matches!(
1✔
626
                *right,
1✔
627
                AssertionExpr::Atom(Expr::Literal(Literal::Number(_)))
628
            ));
629
        } else {
NEW
630
            panic!("Expected Binary");
×
631
        }
632
    }
1✔
633

634
    #[test]
635
    fn test_parse_plugin_call() {
1✔
636
        let expr = parse_assertion("@uuid(.user_id) == true");
1✔
637
        if let AssertionExpr::Binary { op, left, .. } = expr {
1✔
638
            assert_eq!(op, BinaryOp::Eq);
1✔
639
            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1✔
640
                assert_eq!(name, "uuid");
1✔
641
                assert_eq!(args.len(), 1);
1✔
642
            } else {
NEW
643
                panic!("Expected PluginCall");
×
644
            }
645
        } else {
NEW
646
            panic!("Expected Binary");
×
647
        }
648
    }
1✔
649

650
    #[test]
651
    fn test_parse_negation() {
1✔
652
        let expr = parse_assertion("!@has_header(\"x\")");
1✔
653
        if let AssertionExpr::Not(inner) = expr {
1✔
654
            if let AssertionExpr::Atom(Expr::PluginCall { name, .. }) = &*inner {
1✔
655
                assert_eq!(name, "has_header");
1✔
656
            } else {
NEW
657
                panic!("Expected PluginCall");
×
658
            }
659
        } else {
NEW
660
            panic!("Expected Not");
×
661
        }
662
    }
1✔
663

664
    #[test]
665
    fn test_parse_contains() {
1✔
666
        let expr = parse_assertion(".name contains \"test\"");
1✔
667
        if let AssertionExpr::Binary { op, left, right } = expr {
1✔
668
            assert_eq!(op, BinaryOp::Contains);
1✔
669
            assert!(matches!(*left, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
670
            assert!(matches!(
1✔
671
                *right,
1✔
672
                AssertionExpr::Atom(Expr::Literal(Literal::Str(_)))
673
            ));
674
        } else {
NEW
675
            panic!("Expected Binary");
×
676
        }
677
    }
1✔
678

679
    #[test]
680
    fn test_parse_startswith() {
1✔
681
        let expr = parse_assertion(".name startsWith \"te\"");
1✔
682
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
683
            assert_eq!(op, BinaryOp::StartsWith);
1✔
684
        } else {
NEW
685
            panic!("Expected Binary");
×
686
        }
687
    }
1✔
688

689
    #[test]
690
    fn test_parse_matches() {
1✔
691
        let expr = parse_assertion(".name matches \"^te.*t$\"");
1✔
692
        if let AssertionExpr::Binary { op, .. } = expr {
1✔
693
            assert_eq!(op, BinaryOp::Matches);
1✔
694
        } else {
NEW
695
            panic!("Expected Binary");
×
696
        }
697
    }
1✔
698

699
    #[test]
700
    fn test_parse_if_then_else() {
1✔
701
        let expr = parse_assertion("if @len(.items) == 0 then true else false end");
1✔
702
        if let AssertionExpr::IfThenElse {
703
            condition,
1✔
704
            then_branch,
1✔
705
            else_branch,
1✔
706
        } = expr
1✔
707
        {
708
            assert!(matches!(*condition, AssertionExpr::Binary { .. }));
1✔
709
            assert!(matches!(
1✔
710
                *then_branch,
1✔
711
                AssertionExpr::Atom(Expr::Literal(Literal::Bool(true)))
712
            ));
713
            assert!(matches!(
1✔
714
                *else_branch,
1✔
715
                AssertionExpr::Atom(Expr::Literal(Literal::Bool(false)))
716
            ));
717
        } else {
NEW
718
            panic!("Expected IfThenElse");
×
719
        }
720
    }
1✔
721

722
    #[test]
723
    fn test_if_then_else_serializes_as_ternary() {
1✔
724
        let expr = parse_assertion("if .x == 0 then true else false end");
1✔
725
        let s = assertion_to_string(&expr);
1✔
726
        assert!(s.contains('?'), "Should contain ternary: {}", s);
1✔
727
        assert!(s.contains(':'), "Should contain colon: {}", s);
1✔
728
    }
1✔
729

730
    #[test]
731
    fn test_nested_if_serializes_correctly() {
1✔
732
        let expr =
1✔
733
            parse_assertion("if .a == 1 then if .b == 2 then \"A\" else \"B\" end else \"C\" end");
1✔
734
        if let AssertionExpr::IfThenElse {
735
            then_branch,
1✔
736
            else_branch,
1✔
737
            ..
738
        } = &expr
1✔
739
        {
740
            assert!(matches!(**then_branch, AssertionExpr::IfThenElse { .. }));
1✔
741
            assert!(matches!(
1✔
742
                **else_branch,
1✔
743
                AssertionExpr::Atom(Expr::Literal(Literal::Str(_)))
744
            ));
745
        } else {
NEW
746
            panic!("Expected IfThenElse");
×
747
        }
748
        let s = assertion_to_string(&expr);
1✔
749
        assert!(s.contains('('), "Nested ternary should have parens: {}", s);
1✔
750
    }
1✔
751

752
    #[test]
753
    fn test_remove_redundant_parens() {
1✔
754
        let expr = parse_assertion("((.x == 1))");
1✔
755
        let simplified = remove_redundant_parens(&expr);
1✔
756
        let s = assertion_to_string(&simplified);
1✔
757
        assert!(!s.starts_with("(("), "Should not have double parens: {}", s);
1✔
758
    }
1✔
759

760
    #[test]
761
    fn test_roundtrip_simple() {
1✔
762
        let original = ".id == 123";
1✔
763
        let expr = parse_assertion(original);
1✔
764
        assert_eq!(assertion_to_string(&expr), original);
1✔
765
    }
1✔
766

767
    #[test]
768
    fn test_roundtrip_with_plugin() {
1✔
769
        let original = "@len(.items) == 0";
1✔
770
        let expr = parse_assertion(original);
1✔
771
        assert_eq!(assertion_to_string(&expr), original);
1✔
772
    }
1✔
773

774
    #[test]
775
    fn test_parse_and_or() {
1✔
776
        assert!(matches!(
1✔
777
            parse_assertion(".x == 1 and .y == 2"),
1✔
778
            AssertionExpr::And { .. }
779
        ));
780
        assert!(matches!(
1✔
781
            parse_assertion(".x == 1 or .y == 2"),
1✔
782
            AssertionExpr::Or { .. }
783
        ));
784
    }
1✔
785

786
    #[test]
787
    fn test_parse_not_not() {
1✔
788
        if let AssertionExpr::NotNot(inner) = parse_assertion("not not .x") {
1✔
789
            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
790
        } else {
NEW
791
            panic!("Expected NotNot");
×
792
        }
793
    }
1✔
794

795
    #[test]
796
    fn test_parse_double_bang() {
1✔
797
        if let AssertionExpr::NotNot(inner) = parse_assertion("!!.x") {
1✔
798
            assert!(matches!(*inner, AssertionExpr::Atom(Expr::JqPath(_))));
1✔
799
        } else {
NEW
800
            panic!("Expected NotNot");
×
801
        }
802
    }
1✔
803

804
    #[test]
805
    fn test_parse_regex_literal() {
1✔
806
        let expr = parse_assertion("@regex(.name, /^hello/i) == true");
1✔
807
        if let AssertionExpr::Binary { op, left, .. } = expr {
1✔
808
            assert_eq!(op, BinaryOp::Eq);
1✔
809
            if let AssertionExpr::Atom(Expr::PluginCall { name, args }) = &*left {
1✔
810
                assert_eq!(name, "regex");
1✔
811
                assert_eq!(args.len(), 2);
1✔
812
                if let AssertionExpr::Atom(a) = &args[1] {
1✔
813
                    if let Expr::RegExp { pattern, flags } = &a {
1✔
814
                        assert_eq!(pattern, "^hello");
1✔
815
                        assert_eq!(flags, "");
1✔
816
                    } else {
NEW
817
                        panic!("Expected RegExp");
×
818
                    }
819
                } else {
NEW
820
                    panic!("Expected Atom");
×
821
                }
822
            } else {
NEW
823
                panic!("Expected PluginCall");
×
824
            }
825
        } else {
NEW
826
            panic!("Expected Binary");
×
827
        }
828
    }
1✔
829

830
    #[test]
831
    fn test_parse_regex_serializes_correctly() {
1✔
832
        let expr = parse_assertion("@regex(.x, /\\d{4}/gi) == true");
1✔
833
        let s = assertion_to_string(&expr);
1✔
834
        assert!(s.contains("/\\d{4}/"), "Should contain regex: {}", s);
1✔
835
    }
1✔
836

837
    #[test]
838
    fn test_parse_json_literal() {
1✔
839
        let expr = parse_assertion("@json(.data) == {\"key\": \"value\"}");
1✔
840
        if let AssertionExpr::Binary { op, right, .. } = expr {
1✔
841
            assert_eq!(op, BinaryOp::Eq);
1✔
842
            if let AssertionExpr::Atom(Expr::Json(s)) = &*right {
1✔
843
                assert!(s.contains("\"key\""));
1✔
844
                assert!(s.contains("\"value\""));
1✔
845
            } else {
NEW
846
                panic!("Expected Json");
×
847
            }
848
        } else {
NEW
849
            panic!("Expected Binary");
×
850
        }
851
    }
1✔
852

853
    #[test]
854
    fn test_nested_ternary_roundtrip() {
1✔
855
        let original = "if .x == 1 then if .y == 2 then true else false end else false end";
1✔
856
        let expr = parse_assertion(original);
1✔
857
        let s = assertion_to_string(&expr);
1✔
858
        assert!(s.contains('?'), "Should contain ternary: {}", s);
1✔
859
        assert!(s.contains('('), "Should contain parens: {}", s);
1✔
860
    }
1✔
861
}
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