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

gripmock / grpctestify-rust / 24849353019

23 Apr 2026 05:30PM UTC coverage: 77.721% (+0.8%) from 76.897%
24849353019

Pull #42

github

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

1068 of 1260 new or added lines in 25 files covered. (84.76%)

95 existing lines in 7 files now uncovered.

18862 of 24269 relevant lines covered (77.72%)

40957.95 hits per line

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

92.76
/src/optimizer/mod.rs
1
use serde::{Deserialize, Deserializer, Serialize, Serializer};
2
use std::borrow::Cow;
3
use std::collections::{HashMap, HashSet};
4
use std::sync::LazyLock;
5

6
use crate::parser;
7
use crate::parser::assertions::strip_assertion_comments;
8
use crate::plugins::{PluginSignature, TypeInfo, extract_plugin_call_name};
9
use crate::utils::section_content_line;
10

11
fn likely_needs_assertion_rewrite(expr: &str) -> bool {
129✔
12
    expr.contains("==")
129✔
13
        || expr.contains("!=")
56✔
14
        || expr.contains('>')
49✔
15
        || expr.contains('<')
30✔
16
        || expr.contains(" startswith ")
30✔
17
        || expr.contains(" endswith ")
26✔
18
        || expr.contains("!!")
26✔
19
        || expr.contains("not not ")
22✔
20
        || expr.contains("if ")
20✔
21
        || expr.contains(" then ")
15✔
22
        || expr.contains(" else ")
15✔
23
        || expr.contains(" or ")
15✔
24
        || expr.contains(" and ")
13✔
25
        || expr.contains("@len(")
13✔
26
        || expr.contains(">= 0")
13✔
27
        || expr.contains("<= @")
13✔
28
        || expr.starts_with('(')
13✔
29
}
129✔
30

31
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32
enum NormalizationMode {
33
    #[cfg(test)]
34
    Conservative,
35
    AstCanonical,
36
}
37

38
fn normalization_mode() -> NormalizationMode {
205✔
39
    NormalizationMode::AstCanonical
205✔
40
}
205✔
41

42
fn normalize_expr_for_optimizer_with_mode<'a>(
482✔
43
    expr: &'a str,
482✔
44
    mode: NormalizationMode,
482✔
45
) -> Cow<'a, str> {
482✔
46
    let trimmed = expr.trim();
482✔
47
    match mode {
482✔
48
        #[cfg(test)]
49
        NormalizationMode::Conservative => Cow::Borrowed(trimmed),
142✔
50
        NormalizationMode::AstCanonical => canonicalize_expr_with_ast(trimmed)
340✔
51
            .map(Cow::Owned)
340✔
52
            .unwrap_or_else(|| Cow::Borrowed(trimmed)),
340✔
53
    }
54
}
482✔
55

56
fn canonicalize_expr_with_ast(expr: &str) -> Option<String> {
340✔
57
    use crate::parser::assertion_ast::AssertionExpr;
58

59
    fn ast_to_if_string(expr: &AssertionExpr, out: &mut String, prec: u8) {
902✔
60
        match expr {
902✔
61
            AssertionExpr::Or { left, right } => {
8✔
62
                if prec > 1 {
8✔
NEW
63
                    out.push('(');
×
64
                }
8✔
65
                ast_to_if_string(left, out, 1);
8✔
66
                out.push_str(" or ");
8✔
67
                ast_to_if_string(right, out, 1);
8✔
68
                if prec > 1 {
8✔
NEW
69
                    out.push(')');
×
70
                }
8✔
71
            }
NEW
72
            AssertionExpr::Xor { left, right } => {
×
NEW
73
                if prec > 1 {
×
NEW
74
                    out.push('(');
×
NEW
75
                }
×
NEW
76
                ast_to_if_string(left, out, 1);
×
NEW
77
                out.push_str(" xor ");
×
NEW
78
                ast_to_if_string(right, out, 1);
×
NEW
79
                if prec > 1 {
×
NEW
80
                    out.push(')');
×
NEW
81
                }
×
82
            }
83
            AssertionExpr::And { left, right } => {
9✔
84
                if prec > 2 {
9✔
NEW
85
                    out.push('(');
×
86
                }
9✔
87
                ast_to_if_string(left, out, 2);
9✔
88
                out.push_str(" and ");
9✔
89
                ast_to_if_string(right, out, 2);
9✔
90
                if prec > 2 {
9✔
NEW
91
                    out.push(')');
×
92
                }
9✔
93
            }
94
            AssertionExpr::Binary { op, left, right } => {
194✔
95
                if prec > 3 {
194✔
96
                    out.push('(');
8✔
97
                }
186✔
98
                ast_to_if_string(left, out, 3);
194✔
99
                out.push(' ');
194✔
100
                out.push_str(op.as_str());
194✔
101
                out.push(' ');
194✔
102
                ast_to_if_string(right, out, 3);
194✔
103
                if prec > 3 {
194✔
104
                    out.push(')');
8✔
105
                }
186✔
106
            }
107
            AssertionExpr::Not(inner) => {
26✔
108
                out.push('!');
26✔
109
                ast_to_if_string(inner, out, 4);
26✔
110
            }
26✔
111
            AssertionExpr::NotNot(inner) => {
18✔
112
                out.push_str("not not ");
18✔
113
                ast_to_if_string(inner, out, 4);
18✔
114
            }
18✔
115
            AssertionExpr::IfThenElse {
116
                condition,
32✔
117
                then_branch,
32✔
118
                else_branch,
32✔
119
            } => {
32✔
120
                out.push_str("if ");
32✔
121
                ast_to_if_string(condition, out, 0);
32✔
122
                out.push_str(" then ");
32✔
123
                ast_to_if_string(then_branch, out, 0);
32✔
124
                out.push_str(" else ");
32✔
125
                ast_to_if_string(else_branch, out, 0);
32✔
126
                out.push_str(" end");
32✔
127
            }
32✔
NEW
128
            AssertionExpr::Paren(inner) => {
×
NEW
129
                out.push('(');
×
NEW
130
                ast_to_if_string(inner, out, 0);
×
NEW
131
                out.push(')');
×
NEW
132
            }
×
133
            AssertionExpr::Atom(atom) => out.push_str(&atom.to_string()),
614✔
134
            AssertionExpr::Raw(raw) => out.push_str(raw),
1✔
135
        }
136
    }
902✔
137

138
    if expr.is_empty() {
340✔
NEW
139
        return None;
×
140
    }
340✔
141

142
    let parsed = parser::assertion_ast::parse_assertion(expr);
340✔
143
    let reduced = parser::assertion_ast::remove_redundant_parens(&parsed);
340✔
144
    let mut out = String::with_capacity(expr.len());
340✔
145
    ast_to_if_string(&reduced, &mut out, 0);
340✔
146
    Some(out)
340✔
147
}
340✔
148

149
#[derive(Debug, Clone, Copy)]
150
struct RewriteRuleMetadata {
151
    id: RuleId,
152
    preconditions: &'static str,
153
    negative_cases: &'static str,
154
    proof_note: &'static str,
155
}
156

157
macro_rules! rule_id_table {
158
    ($($name:ident => $value:literal),+ $(,)?) => {
159
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
160
        pub enum RuleId {
161
            $($name),+
162
        }
163

164
        impl RuleId {
165
            pub const fn as_str(self) -> &'static str {
13✔
166
                match self {
13✔
167
                    $(Self::$name => $value),+
168
                }
169
            }
13✔
170
        }
171

172
        impl TryFrom<&str> for RuleId {
173
            type Error = &'static str;
174

NEW
175
            fn try_from(value: &str) -> Result<Self, Self::Error> {
×
NEW
176
                match value {
×
NEW
177
                    $($value => Ok(Self::$name)),+,
×
NEW
178
                    _ => Err("unknown optimizer rule id"),
×
179
                }
NEW
180
            }
×
181
        }
182

183
        pub mod rule_ids {
184
            use super::RuleId;
185
            $(pub const $name: RuleId = RuleId::$name;)+
186
        }
187
    };
188
}
189

190
rule_id_table! {
191
    B001 => "OPT_B001",
192
    B002 => "OPT_B002",
193
    B003 => "OPT_B003",
194
    B004 => "OPT_B004",
195
    B005 => "OPT_B005",
196
    B006 => "OPT_B006",
197
    B007 => "OPT_B007",
198
    B008 => "OPT_B008",
199
    B009 => "OPT_B009",
200
    B010 => "OPT_B010",
201
    B013 => "OPT_B013",
202
    B014 => "OPT_B014",
203
    B015 => "OPT_B015",
204
    B016 => "OPT_B016",
205
    B017 => "OPT_B017",
206
    N001 => "OPT_N001",
207
    N002 => "OPT_N002",
208
    I001 => "OPT_I001",
209
    I002 => "OPT_I002",
210
    I003 => "OPT_I003",
211
    I004 => "OPT_I004",
212
    I005 => "OPT_I005",
213
    P001 => "OPT_P001",
214
    P002 => "OPT_P002",
215
    T001 => "OPT_T001",
216
}
217

218
impl std::fmt::Display for RuleId {
NEW
219
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
×
NEW
220
        f.write_str(self.as_str())
×
NEW
221
    }
×
222
}
223

224
impl Serialize for RuleId {
NEW
225
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
×
NEW
226
    where
×
NEW
227
        S: Serializer,
×
228
    {
NEW
229
        serializer.serialize_str(self.as_str())
×
NEW
230
    }
×
231
}
232

233
impl<'de> Deserialize<'de> for RuleId {
NEW
234
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
×
NEW
235
    where
×
NEW
236
        D: Deserializer<'de>,
×
237
    {
NEW
238
        let s = String::deserialize(deserializer)?;
×
NEW
239
        RuleId::try_from(s.as_str()).map_err(serde::de::Error::custom)
×
NEW
240
    }
×
241
}
242

243
const REWRITE_RULES: &[RewriteRuleMetadata] = &[
244
    RewriteRuleMetadata {
245
        id: rule_ids::B001,
246
        preconditions: "lhs is boolean plugin expr and rhs is true",
247
        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
248
        proof_note: "Boolean identity: expr == true is equivalent to expr",
249
    },
250
    RewriteRuleMetadata {
251
        id: rule_ids::B002,
252
        preconditions: "lhs is boolean plugin expr and rhs is false",
253
        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
254
        proof_note: "Boolean negation: expr == false is equivalent to !expr",
255
    },
256
    RewriteRuleMetadata {
257
        id: rule_ids::B003,
258
        preconditions: "lhs is true and rhs is boolean plugin expr",
259
        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
260
        proof_note: "Boolean identity: true == expr is equivalent to expr",
261
    },
262
    RewriteRuleMetadata {
263
        id: rule_ids::B004,
264
        preconditions: "lhs is false and rhs is boolean plugin expr",
265
        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
266
        proof_note: "Boolean negation: false == expr is equivalent to !expr",
267
    },
268
    RewriteRuleMetadata {
269
        id: rule_ids::B005,
270
        preconditions: "expression has form !!<bool-plugin-expr>",
271
        negative_cases: "inner expr is not proven boolean-safe",
272
        proof_note: "Double negation elimination for boolean expressions",
273
    },
274
    RewriteRuleMetadata {
275
        id: rule_ids::B006,
276
        preconditions: "binary compare over two literals only",
277
        negative_cases: "contains non-literals, dynamic plugin calls, or unknown values",
278
        proof_note: "Constant folding preserves comparison result",
279
    },
280
    RewriteRuleMetadata {
281
        id: rule_ids::B007,
282
        preconditions: "expression has form x == x and x is idempotent",
283
        negative_cases: "x may be non-idempotent or side-effectful",
284
        proof_note: "Reflexive equality over idempotent expressions is always true",
285
    },
286
    RewriteRuleMetadata {
287
        id: rule_ids::B008,
288
        preconditions: "expression has form x != x and x is idempotent",
289
        negative_cases: "x may be non-idempotent or side-effectful",
290
        proof_note: "Reflexive inequality over idempotent expressions is always false",
291
    },
292
    RewriteRuleMetadata {
293
        id: rule_ids::B013,
294
        preconditions: "lhs is boolean plugin expr and rhs is true",
295
        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
296
        proof_note: "Boolean negation: expr != true is equivalent to !expr",
297
    },
298
    RewriteRuleMetadata {
299
        id: rule_ids::B014,
300
        preconditions: "lhs is boolean plugin expr and rhs is false",
301
        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
302
        proof_note: "Boolean identity: expr != false is equivalent to expr",
303
    },
304
    RewriteRuleMetadata {
305
        id: rule_ids::B015,
306
        preconditions: "lhs is true and rhs is boolean plugin expr",
307
        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
308
        proof_note: "Boolean negation: true != expr is equivalent to !expr",
309
    },
310
    RewriteRuleMetadata {
311
        id: rule_ids::B016,
312
        preconditions: "lhs is false and rhs is boolean plugin expr",
313
        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
314
        proof_note: "Boolean identity: false != expr is equivalent to expr",
315
    },
316
    RewriteRuleMetadata {
317
        id: rule_ids::B017,
318
        preconditions: "expression has form not not <bool-plugin-expr>",
319
        negative_cases: "inner expr is not proven boolean-safe",
320
        proof_note: "Word-style double negation elimination",
321
    },
322
    RewriteRuleMetadata {
323
        id: rule_ids::N001,
324
        preconditions: "operator alias startswith/endswith is present",
325
        negative_cases: "already canonicalized form",
326
        proof_note: "Canonical spelling rewrite preserves operator semantics",
327
    },
328
    RewriteRuleMetadata {
329
        id: rule_ids::I001,
330
        preconditions: "if-then-else with boolean literal condition",
331
        negative_cases: "condition is not a literal true/false",
332
        proof_note: "Dead branch elimination: if true then A else B end = A",
333
    },
334
    RewriteRuleMetadata {
335
        id: rule_ids::I002,
336
        preconditions: "if-then-else with identical then/else branches",
337
        negative_cases: "branches are different expressions",
338
        proof_note: "Branch merging: if C then X else X end = X",
339
    },
340
    RewriteRuleMetadata {
341
        id: rule_ids::I003,
342
        preconditions: "nested if with redundant condition check",
343
        negative_cases: "conditions are not related",
344
        proof_note: "Condition simplification for nested boolean expressions",
345
    },
346
    RewriteRuleMetadata {
347
        id: rule_ids::I004,
348
        preconditions: "if-then-else with boolean condition and literal branches",
349
        negative_cases: "branches are not boolean literals",
350
        proof_note: "Boolean simplification: if C then true else false end = C",
351
    },
352
    RewriteRuleMetadata {
353
        id: rule_ids::I005,
354
        preconditions: "if-then-else with negated condition pattern",
355
        negative_cases: "branches don't match negation pattern",
356
        proof_note: "Condition inversion: if C then false else true end = !C",
357
    },
358
    RewriteRuleMetadata {
359
        id: rule_ids::B009,
360
        preconditions: "boolean expression OR true/false",
361
        negative_cases: "operand is not boolean literal",
362
        proof_note: "Boolean identity: A or true = true, A or false = A",
363
    },
364
    RewriteRuleMetadata {
365
        id: rule_ids::B010,
366
        preconditions: "boolean expression AND true/false",
367
        negative_cases: "operand is not boolean literal",
368
        proof_note: "Boolean absorption: A and true = A, A and false = false",
369
    },
370
    RewriteRuleMetadata {
371
        id: rule_ids::P001,
372
        preconditions: "@len(expr) compared to zero",
373
        negative_cases: "comparison is not with zero or not @len plugin",
374
        proof_note: "Length check simplification: @len(x) == 0 = @empty(x)",
375
    },
376
    RewriteRuleMetadata {
377
        id: rule_ids::P002,
378
        preconditions: "expression wrapped in outer parentheses only",
379
        negative_cases: "inner expression has internal parentheses (ambiguity risk)",
380
        proof_note: "Redundant parentheses removal: (expr) = expr",
381
    },
382
    RewriteRuleMetadata {
383
        id: rule_ids::N002,
384
        preconditions: "negation of comparison operator",
385
        negative_cases: "inner expression is not a comparison",
386
        proof_note: "Comparison negation: not (A == B) = A != B",
387
    },
388
    RewriteRuleMetadata {
389
        id: rule_ids::T001,
390
        preconditions: "UInt-returning plugin (e.g. @len) compared with 0 via >= or <=",
391
        negative_cases: "plugin does not return UInt or comparison is not with 0",
392
        proof_note: "Unsigned integers are always >= 0, so @uint() >= 0 = true",
393
    },
394
];
395

396
fn rule_metadata(rule_id: RuleId) -> Option<&'static RewriteRuleMetadata> {
117✔
397
    REWRITE_RULES.iter().find(|r| r.id == rule_id)
1,345✔
398
}
117✔
399

400
#[derive(Debug, Clone, Serialize, Deserialize)]
401
pub struct OptimizationHint {
402
    pub rule_id: RuleId,
403
    pub line: usize,
404
    pub before: String,
405
    pub after: String,
406
    #[serde(skip_serializing_if = "Option::is_none")]
407
    pub preconditions: Option<String>,
408
    #[serde(skip_serializing_if = "Option::is_none")]
409
    pub negative_cases: Option<String>,
410
    #[serde(skip_serializing_if = "Option::is_none")]
411
    pub proof_note: Option<String>,
412
}
413

414
fn build_hint(rule_id: RuleId, line: usize, before: &str, after: String) -> OptimizationHint {
46✔
415
    let meta = rule_metadata(rule_id);
46✔
416
    OptimizationHint {
417
        rule_id,
46✔
418
        line,
46✔
419
        before: before.to_string(),
46✔
420
        after,
46✔
421
        preconditions: meta.map(|m| m.preconditions.to_string()),
46✔
422
        negative_cases: meta.map(|m| m.negative_cases.to_string()),
46✔
423
        proof_note: meta.map(|m| m.proof_note.to_string()),
46✔
424
    }
425
}
46✔
426

427
use crate::plugins::PLUGIN_SIGNATURES;
428

429
static BOOLEAN_PLUGINS: LazyLock<HashSet<String>> = LazyLock::new(|| {
32✔
430
    PLUGIN_SIGNATURES
32✔
431
        .iter()
32✔
432
        .filter(|(_, signature)| {
544✔
433
            signature.return_type == TypeInfo::Bool
544✔
434
                && signature.safe_for_rewrite
288✔
435
                && signature.deterministic
288✔
436
                && signature.idempotent
288✔
437
        })
544✔
438
        .map(|(name, _)| name.clone())
288✔
439
        .collect()
32✔
440
});
32✔
441

442
fn plugin_signatures() -> &'static HashMap<String, PluginSignature> {
727✔
443
    &PLUGIN_SIGNATURES
727✔
444
}
727✔
445

446
fn boolean_plugins() -> &'static HashSet<String> {
396✔
447
    &BOOLEAN_PLUGINS
396✔
448
}
396✔
449

450
fn is_boolean_plugin_expr(expr: &str, bool_plugins: &HashSet<String>) -> bool {
386✔
451
    let Some(plugin_name) = extract_plugin_call_name(expr) else {
386✔
452
        return false;
228✔
453
    };
454

455
    bool_plugins.contains(plugin_name.as_str())
158✔
456
}
386✔
457

458
fn suggest_boolean_rewrite(expr: &str, bool_plugins: &HashSet<String>) -> Option<(RuleId, String)> {
482✔
459
    let (lhs, rhs) = expr.split_once("==")?;
482✔
460
    let lhs = lhs.trim();
135✔
461
    let rhs = rhs.trim();
135✔
462

463
    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "true" {
135✔
464
        return Some((rule_ids::B001, lhs.to_string()));
17✔
465
    }
118✔
466
    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "false" {
118✔
467
        return Some((rule_ids::B002, format!("!{}", lhs)));
11✔
468
    }
107✔
469
    if lhs == "true" && is_boolean_plugin_expr(rhs, bool_plugins) {
107✔
470
        return Some((rule_ids::B003, rhs.to_string()));
3✔
471
    }
104✔
472
    if lhs == "false" && is_boolean_plugin_expr(rhs, bool_plugins) {
104✔
473
        return Some((rule_ids::B004, format!("!{}", rhs)));
2✔
474
    }
102✔
475

476
    None
102✔
477
}
482✔
478

479
fn suggest_not_not_rewrite(expr: &str, bool_plugins: &HashSet<String>) -> Option<(RuleId, String)> {
449✔
480
    let trimmed = expr.trim();
449✔
481
    if !trimmed.starts_with("not not ") {
449✔
482
        return None;
428✔
483
    }
21✔
484

485
    let inner = trimmed[8..].trim();
21✔
486
    if is_boolean_plugin_expr(inner, bool_plugins) {
21✔
487
        return Some((rule_ids::B017, inner.to_string()));
20✔
488
    }
1✔
489

490
    None
1✔
491
}
449✔
492

493
fn suggest_inequality_rewrite(
429✔
494
    expr: &str,
429✔
495
    bool_plugins: &HashSet<String>,
429✔
496
) -> Option<(RuleId, String)> {
429✔
497
    let (lhs, rhs) = expr.split_once("!=")?;
429✔
498
    let lhs = lhs.trim();
46✔
499
    let rhs = rhs.trim();
46✔
500

501
    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "true" {
46✔
502
        return Some((rule_ids::B013, format!("!{}", lhs)));
2✔
503
    }
44✔
504
    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "false" {
44✔
505
        return Some((rule_ids::B014, lhs.to_string()));
1✔
506
    }
43✔
507
    if lhs == "true" && is_boolean_plugin_expr(rhs, bool_plugins) {
43✔
508
        return Some((rule_ids::B015, format!("!{}", rhs)));
10✔
509
    }
33✔
510
    if lhs == "false" && is_boolean_plugin_expr(rhs, bool_plugins) {
33✔
511
        return Some((rule_ids::B016, rhs.to_string()));
1✔
512
    }
32✔
513

514
    None
32✔
515
}
429✔
516

517
/// Redundant parentheses: (expr) -> expr (single expression, no ambiguity)
518
fn suggest_redundant_parens(expr: &str) -> Option<(RuleId, String)> {
401✔
519
    let trimmed = expr.trim();
401✔
520
    if !trimmed.starts_with('(') || !trimmed.ends_with(')') {
401✔
521
        return None;
398✔
522
    }
3✔
523

524
    let inner = &trimmed[1..trimmed.len() - 1].trim();
3✔
525
    if inner.is_empty() {
3✔
526
        return None;
×
527
    }
3✔
528

529
    let balanced = inner.chars().fold(0i32, |acc, c| {
48✔
530
        if c == '(' {
48✔
531
            acc + 1
3✔
532
        } else if c == ')' {
45✔
533
            acc - 1
3✔
534
        } else {
535
            acc
42✔
536
        }
537
    });
48✔
538
    if balanced != 0 {
3✔
539
        return None;
×
540
    }
3✔
541

542
    Some((rule_ids::P002, inner.to_string()))
3✔
543
}
401✔
544

545
fn suggest_double_negation_rewrite(
415✔
546
    expr: &str,
415✔
547
    bool_plugins: &HashSet<String>,
415✔
548
) -> Option<(RuleId, String)> {
415✔
549
    let trimmed = expr.trim();
415✔
550
    if !trimmed.starts_with("!!") {
415✔
551
        return None;
412✔
552
    }
3✔
553

554
    let inner = trimmed[2..].trim();
3✔
555
    if is_boolean_plugin_expr(inner, bool_plugins) {
3✔
556
        return Some((rule_ids::B005, inner.to_string()));
3✔
UNCOV
557
    }
×
558

UNCOV
559
    None
×
560
}
415✔
561

562
fn suggest_operator_canonicalization(expr: &str) -> Option<(RuleId, String)> {
412✔
563
    if expr.contains(" startswith ") {
412✔
564
        let rewritten = expr.replace(" startswith ", " startsWith ");
3✔
565
        return Some((rule_ids::N001, rewritten));
3✔
566
    }
409✔
567
    if expr.contains(" endswith ") {
409✔
568
        let rewritten = expr.replace(" endswith ", " endsWith ");
×
NEW
569
        return Some((rule_ids::N001, rewritten));
×
570
    }
409✔
571
    None
409✔
572
}
412✔
573

574
fn parse_literal(expr: &str) -> Option<serde_json::Value> {
200✔
575
    let trimmed = expr.trim();
200✔
576
    if trimmed.is_empty() {
200✔
577
        return None;
×
578
    }
200✔
579

580
    if trimmed == "true" {
200✔
581
        return Some(serde_json::Value::Bool(true));
×
582
    }
200✔
583
    if trimmed == "false" {
200✔
584
        return Some(serde_json::Value::Bool(false));
×
585
    }
200✔
586
    if trimmed == "null" {
200✔
587
        return Some(serde_json::Value::Null);
×
588
    }
200✔
589

590
    if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
200✔
591
        return serde_json::from_str(trimmed).ok();
5✔
592
    }
195✔
593

594
    if let Ok(i) = trimmed.parse::<i64>() {
195✔
595
        return Some(serde_json::Value::Number(serde_json::Number::from(i)));
7✔
596
    }
188✔
597

598
    if let Ok(f) = trimmed.parse::<f64>() {
188✔
599
        return serde_json::Number::from_f64(f).map(serde_json::Value::Number);
×
600
    }
188✔
601

602
    None
188✔
603
}
200✔
604

605
fn suggest_constant_folding(expr: &str) -> Option<(RuleId, String)> {
412✔
606
    let operators = ["==", "!=", ">=", "<=", ">", "<"];
412✔
607
    for op in operators {
2,454✔
608
        let Some(idx) = expr.find(op) else {
2,454✔
609
            continue;
2,270✔
610
        };
611

612
        let lhs_raw = expr[..idx].trim();
184✔
613
        let rhs_raw = expr[idx + op.len()..].trim();
184✔
614
        if lhs_raw.is_empty() || rhs_raw.is_empty() {
184✔
615
            continue;
×
616
        }
184✔
617

618
        let Some(lhs) = parse_literal(lhs_raw) else {
184✔
619
            continue;
178✔
620
        };
621
        let Some(rhs) = parse_literal(rhs_raw) else {
6✔
622
            continue;
×
623
        };
624

625
        let folded = match op {
6✔
626
            "==" => Some(lhs == rhs),
6✔
627
            "!=" => Some(lhs != rhs),
3✔
628
            ">" | "<" | ">=" | "<=" => compare_literal_numbers(&lhs, &rhs, op),
3✔
629
            _ => None,
×
630
        }?;
×
631

632
        return Some((rule_ids::B006, folded.to_string()));
6✔
633
    }
634

635
    None
406✔
636
}
412✔
637

638
fn compare_literal_numbers(
3✔
639
    lhs: &serde_json::Value,
3✔
640
    rhs: &serde_json::Value,
3✔
641
    op: &str,
3✔
642
) -> Option<bool> {
3✔
643
    let lhs_num = lhs.as_number()?;
3✔
644
    let rhs_num = rhs.as_number()?;
3✔
645

646
    let lhs_i = lhs_num
3✔
647
        .as_i64()
3✔
648
        .map(i128::from)
3✔
649
        .or_else(|| lhs_num.as_u64().map(i128::from));
3✔
650
    let rhs_i = rhs_num
3✔
651
        .as_i64()
3✔
652
        .map(i128::from)
3✔
653
        .or_else(|| rhs_num.as_u64().map(i128::from));
3✔
654

655
    if let (Some(l), Some(r)) = (lhs_i, rhs_i) {
3✔
656
        return Some(match op {
3✔
657
            ">" => l > r,
3✔
658
            "<" => l < r,
×
659
            ">=" => l >= r,
×
660
            "<=" => l <= r,
×
661
            _ => unreachable!(),
×
662
        });
663
    }
×
664

665
    let (l, r) = (lhs_num.as_f64()?, rhs_num.as_f64()?);
×
666
    Some(match op {
×
667
        ">" => l > r,
×
668
        "<" => l < r,
×
669
        ">=" => l >= r,
×
670
        "<=" => l <= r,
×
671
        _ => unreachable!(),
×
672
    })
673
}
3✔
674

675
fn is_idempotent_expr(expr: &str, signatures: &HashMap<String, PluginSignature>) -> bool {
5✔
676
    let trimmed = expr.trim();
5✔
677
    if trimmed.is_empty() {
5✔
678
        return false;
×
679
    }
5✔
680

681
    if parse_literal(trimmed).is_some() {
5✔
682
        return true;
×
683
    }
5✔
684

685
    if (trimmed.starts_with("{{") && trimmed.ends_with("}}")) || trimmed.starts_with('.') {
5✔
686
        return true;
4✔
687
    }
1✔
688

689
    if trimmed.starts_with('(') && trimmed.ends_with(')') && trimmed.len() >= 2 {
1✔
690
        return is_idempotent_expr(&trimmed[1..trimmed.len() - 1], signatures);
×
691
    }
1✔
692

693
    if let Some(plugin_name) = extract_plugin_call_name(trimmed) {
1✔
694
        return signatures
1✔
695
            .get(plugin_name.as_str())
1✔
696
            .map(|sig| sig.idempotent)
1✔
697
            .unwrap_or(false);
1✔
698
    }
×
699

700
    false
×
701
}
5✔
702

703
fn suggest_reflexive_idempotent(
405✔
704
    expr: &str,
405✔
705
    signatures: &HashMap<String, PluginSignature>,
405✔
706
) -> Option<(RuleId, String)> {
405✔
707
    let (_op, lhs, rhs, rule_id, result) = if let Some((l, r)) = expr.split_once("==") {
405✔
708
        ("==", l, r, rule_ids::B007, "true")
101✔
709
    } else if let Some((l, r)) = expr.split_once("!=") {
304✔
710
        ("!=", l, r, rule_ids::B008, "false")
32✔
711
    } else {
712
        return None;
272✔
713
    };
714

715
    let lhs = lhs.trim();
133✔
716
    let rhs = rhs.trim();
133✔
717

718
    if lhs.is_empty() || rhs.is_empty() || lhs != rhs {
133✔
719
        return None;
128✔
720
    }
5✔
721

722
    if parse_literal(lhs).is_some() && parse_literal(rhs).is_some() {
5✔
723
        return None;
×
724
    }
5✔
725

726
    if !is_idempotent_expr(lhs, signatures) {
5✔
727
        return None;
1✔
728
    }
4✔
729

730
    Some((rule_id, result.to_string()))
4✔
731
}
405✔
732

733
/// Parse if-then-else expression and extract parts
734
fn parse_if_then_else(expr: &str) -> Option<(&str, &str, &str)> {
1,940✔
735
    let expr = expr.trim();
1,940✔
736

737
    if !expr.starts_with("if ") {
1,940✔
738
        return None;
1,792✔
739
    }
148✔
740

741
    let bytes = expr.as_bytes();
148✔
742
    let mut paren_depth = 0;
148✔
743
    let mut if_depth = 0;
148✔
744
    let mut then_pos = None;
148✔
745

746
    let mut i = 0;
148✔
747
    let mut in_string = false;
148✔
748
    let mut string_char = None;
148✔
749
    while i < bytes.len() {
2,403✔
750
        // Handle string literals
751
        if in_string {
2,403✔
752
            if bytes[i] == string_char.unwrap() && (i == 0 || bytes[i - 1] != b'\\') {
103✔
753
                in_string = false;
49✔
754
            }
54✔
755
            i += 1;
103✔
756
            continue;
103✔
757
        }
2,300✔
758
        if bytes[i] == b'"' || bytes[i] == b'\'' {
2,300✔
759
            in_string = true;
49✔
760
            string_char = Some(bytes[i]);
49✔
761
            i += 1;
49✔
762
            continue;
49✔
763
        }
2,251✔
764

765
        match &bytes[i..i + 1] {
2,251✔
766
            b"(" => paren_depth += 1,
2,251✔
767
            b")" => paren_depth -= 1,
47✔
768
            _ => {}
2,157✔
769
        }
770

771
        if paren_depth == 0 && i + 3 <= bytes.len() && &bytes[i..i + 3] == b"if " {
2,251✔
772
            if_depth += 1;
148✔
773
        }
2,103✔
774

775
        if paren_depth == 0
2,251✔
776
            && if_depth == 1
2,204✔
777
            && i + 6 <= bytes.len()
2,204✔
778
            && &bytes[i..i + 6] == b" then "
2,204✔
779
        {
780
            then_pos = Some(i);
148✔
781
            break;
148✔
782
        }
2,103✔
783

784
        i += 1;
2,103✔
785
    }
786

787
    let then_pos = then_pos?;
148✔
788
    let condition = expr[3..then_pos].trim();
148✔
789

790
    let rest = &expr[then_pos + 6..];
148✔
791
    let bytes = rest.as_bytes();
148✔
792
    let mut else_pos = None;
148✔
793
    let mut nested_if = 0;
148✔
794
    paren_depth = 0;
148✔
795

796
    let mut in_string = false;
148✔
797
    let mut string_char = None;
148✔
798

799
    i = 0;
148✔
800
    while i < bytes.len() {
976✔
801
        if in_string {
976✔
802
            if bytes[i] == string_char.unwrap() && (i == 0 || bytes[i - 1] != b'\\') {
245✔
803
                in_string = false;
44✔
804
            }
201✔
805
            i += 1;
245✔
806
            continue;
245✔
807
        }
731✔
808
        if bytes[i] == b'"' || bytes[i] == b'\'' {
731✔
809
            in_string = true;
44✔
810
            string_char = Some(bytes[i]);
44✔
811
            i += 1;
44✔
812
            continue;
44✔
813
        }
687✔
814

815
        match &bytes[i..i + 1] {
687✔
816
            b"(" => paren_depth += 1,
687✔
817
            b")" => paren_depth -= 1,
2✔
818
            _ => {}
683✔
819
        }
820

821
        if paren_depth == 0 && i + 3 <= bytes.len() && &bytes[i..i + 3] == b"if " {
687✔
822
            nested_if += 1;
×
823
        }
687✔
824

825
        if paren_depth == 0 && i + 6 <= bytes.len() && &bytes[i..i + 6] == b" else " {
687✔
826
            if nested_if == 0 {
148✔
827
                else_pos = Some(i);
148✔
828
                break;
148✔
829
            }
×
830
            nested_if -= 1;
×
831
        }
539✔
832

833
        i += 1;
539✔
834
    }
835

836
    let else_pos = else_pos?;
148✔
837
    let then_expr = rest[..else_pos].trim();
148✔
838

839
    let else_and_end = &rest[else_pos + 6..];
148✔
840
    let else_expr = else_and_end.strip_suffix(" end")?.trim();
148✔
841

842
    Some((condition, then_expr, else_expr))
148✔
843
}
1,940✔
844

845
/// Dead branch elimination: if true then A else B = A
846
fn suggest_dead_branch_elimination(expr: &str) -> Option<(RuleId, String)> {
400✔
847
    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
400✔
848

849
    if condition == "true" {
46✔
850
        return Some((rule_ids::I001, then_expr.to_string()));
12✔
851
    }
34✔
852

853
    if condition == "false" {
34✔
854
        return Some((rule_ids::I001, else_expr.to_string()));
1✔
855
    }
33✔
856

857
    None
33✔
858
}
400✔
859

860
/// Branch merging: if C then X else X = X
861
fn suggest_branch_merging(expr: &str) -> Option<(RuleId, String)> {
388✔
862
    let (_condition, then_expr, else_expr) = parse_if_then_else(expr)?;
388✔
863

864
    if then_expr == else_expr {
34✔
865
        return Some((rule_ids::I002, then_expr.to_string()));
12✔
866
    }
22✔
867

868
    None
22✔
869
}
388✔
870

871
/// Nested if simplification: if A then (if A then X else Y) else Z = if A then X else Z
872
fn suggest_nested_if_simplification(expr: &str) -> Option<(RuleId, String)> {
377✔
873
    let (outer_cond, inner_expr, else_expr) = parse_if_then_else(expr)?;
377✔
874

875
    // Strip parentheses from inner expression if present
876
    let inner_stripped = inner_expr.trim();
23✔
877
    let inner_stripped = if inner_stripped.starts_with('(') && inner_stripped.ends_with(')') {
23✔
878
        &inner_stripped[1..inner_stripped.len() - 1]
1✔
879
    } else {
880
        inner_stripped
22✔
881
    };
882

883
    let (inner_cond, inner_then, _inner_else) = parse_if_then_else(inner_stripped)?;
23✔
884

885
    if outer_cond == inner_cond {
1✔
886
        let result = format!(
1✔
887
            "if {} then {} else {} end",
888
            outer_cond, inner_then, else_expr
889
        );
890
        return Some((rule_ids::I003, result));
1✔
891
    }
×
892

893
    None
×
894
}
377✔
895

896
/// Boolean simplification: if C then true else false = C
897
fn suggest_boolean_simplification(expr: &str) -> Option<(RuleId, String)> {
377✔
898
    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
377✔
899

900
    if then_expr == "true" && else_expr == "false" {
23✔
901
        return Some((rule_ids::I004, condition.to_string()));
12✔
902
    }
11✔
903

904
    None
11✔
905
}
377✔
906

907
fn needs_parens_for_prefix_not(expr: &str) -> bool {
5✔
908
    use crate::parser::assertion_ast::AssertionExpr;
909

910
    let parsed = parser::assertion_ast::parse_assertion(expr.trim());
5✔
911
    let reduced = parser::assertion_ast::remove_redundant_parens(&parsed);
5✔
912

913
    !matches!(reduced, AssertionExpr::Atom(_))
5✔
914
}
5✔
915

916
fn negate_condition_expr(condition: &str) -> String {
17✔
917
    if let Some(negated) = negate_comparison_expr(condition) {
17✔
918
        return negated;
12✔
919
    }
5✔
920

921
    let c = condition.trim();
5✔
922
    if c.starts_with('(') && c.ends_with(')') {
5✔
NEW
923
        return format!("!{}", c);
×
924
    }
5✔
925

926
    if needs_parens_for_prefix_not(c) {
5✔
927
        format!("!({})", c)
4✔
928
    } else {
929
        format!("!{}", c)
1✔
930
    }
931
}
17✔
932

933
/// Condition inversion: if C then false else true = !(C)
934
fn suggest_condition_inversion(expr: &str) -> Option<(RuleId, String)> {
371✔
935
    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
371✔
936

937
    if then_expr == "false" && else_expr == "true" {
17✔
938
        Some((rule_ids::I005, negate_condition_expr(condition)))
17✔
939
    } else {
NEW
940
        None
×
941
    }
942
}
371✔
943

944
/// Boolean identity/absorption: A or true = true, A and false = false
945
fn suggest_boolean_identity_laws(expr: &str) -> Option<(RuleId, String)> {
360✔
946
    let expr = expr.trim();
360✔
947

948
    // Check for "or true" / "or false"
949
    if let Some(or_pos) = expr.find(" or ") {
360✔
950
        let left = expr[..or_pos].trim();
14✔
951
        let right = expr[or_pos + 4..].trim();
14✔
952

953
        if right == "true" || left == "true" {
14✔
954
            return Some((rule_ids::B009, "true".to_string()));
13✔
955
        }
1✔
956
        if right == "false" {
1✔
957
            return Some((rule_ids::B009, left.to_string()));
1✔
958
        }
×
959
        if left == "false" {
×
NEW
960
            return Some((rule_ids::B009, right.to_string()));
×
961
        }
×
962
    }
346✔
963

964
    // Check for "and true" / "and false"
965
    if let Some(and_pos) = expr.find(" and ") {
346✔
966
        let left = expr[..and_pos].trim();
18✔
967
        let right = expr[and_pos + 5..].trim();
18✔
968

969
        if left == "true" {
18✔
NEW
970
            return Some((rule_ids::B010, right.to_string()));
×
971
        }
18✔
972
        if right == "true" {
18✔
973
            return Some((rule_ids::B010, left.to_string()));
1✔
974
        }
17✔
975
        if left == "false" || right == "false" {
17✔
976
            return Some((rule_ids::B010, "false".to_string()));
2✔
977
        }
15✔
978
    }
328✔
979

980
    None
343✔
981
}
360✔
982

983
/// Plugin-specific: @len(.x) == 0 → @empty(.x)
984
fn suggest_plugin_length_simplification(expr: &str) -> Option<(RuleId, String)> {
347✔
985
    fn extract_len_inner(s: &str) -> Option<&str> {
25✔
986
        if s.starts_with("@len(") && s.ends_with(')') {
25✔
987
            Some(&s[5..s.len() - 1])
25✔
988
        } else {
NEW
989
            None
×
990
        }
991
    }
25✔
992

993
    fn rewrite_len_zero_cmp(op: &str, inner: &str, len_on_left: bool) -> Option<String> {
25✔
994
        match (op, len_on_left) {
25✔
995
            ("==", _) | ("<=", _) => Some(format!("@empty({})", inner)),
25✔
996
            ("!=", _) => Some(format!("@len({}) > 0", inner)),
12✔
997
            (">", true) => None,
11✔
NEW
998
            (">", false) => Some("false".to_string()),
×
NEW
999
            ("<", true) => Some("false".to_string()),
×
NEW
1000
            ("<", false) => None,
×
NEW
1001
            _ => None,
×
1002
        }
1003
    }
25✔
1004

1005
    let expr = expr.trim();
347✔
1006

1007
    // Patterns: @len(.x) == 0, @len(.x) != 0, @len(.x) > 0
1008
    let operators = [
347✔
1009
        (" == ", "=="),
347✔
1010
        (" != ", "!="),
347✔
1011
        (" > ", ">"),
347✔
1012
        (" < ", "<"),
347✔
1013
        (" <= ", "<="),
347✔
1014
    ];
347✔
1015

1016
    for (op_str, op_name) in operators {
1,658✔
1017
        if let Some(op_pos) = expr.find(op_str) {
1,658✔
1018
            let left = expr[..op_pos].trim();
133✔
1019
            let right = expr[op_pos + op_str.len()..].trim();
133✔
1020

1021
            if right == "0"
133✔
1022
                && let Some(inner) = extract_len_inner(left)
24✔
1023
            {
1024
                return rewrite_len_zero_cmp(op_name, inner, true)
24✔
1025
                    .map(|rewrite| (rule_ids::P001, rewrite));
24✔
1026
            }
109✔
1027

1028
            if left == "0"
109✔
1029
                && let Some(inner) = extract_len_inner(right)
1✔
1030
            {
1031
                return rewrite_len_zero_cmp(op_name, inner, false)
1✔
1032
                    .map(|rewrite| (rule_ids::P001, rewrite));
1✔
1033
            }
108✔
1034
        }
1,525✔
1035
    }
1036

1037
    None
322✔
1038
}
347✔
1039

1040
/// Type-aware numeric comparison optimization.
1041
/// Uses TypeInfo to detect that certain plugins return unsigned integers,
1042
/// making comparisons like `@len(.x) >= 0` always true.
1043
fn suggest_type_aware_numeric_comparison(expr: &str) -> Option<(RuleId, String)> {
332✔
1044
    let signatures = plugin_signatures();
332✔
1045
    let trimmed = expr.trim();
332✔
1046

1047
    let (left, right) = if let Some(idx) = trimmed.find(">=") {
332✔
1048
        (trimmed[..idx].trim(), trimmed[idx + 2..].trim())
11✔
1049
    } else if let Some(idx) = trimmed.find("<=") {
321✔
1050
        (trimmed[..idx].trim(), trimmed[idx + 2..].trim())
×
1051
    } else {
1052
        return None;
321✔
1053
    };
1054

1055
    let plugin_call = if right == "0" {
11✔
1056
        left
11✔
1057
    } else if left == "0" {
×
1058
        right
×
1059
    } else {
1060
        return None;
×
1061
    };
1062

1063
    if let Some(plugin_name) = extract_plugin_call_name(plugin_call)
11✔
1064
        && let Some(sig) = signatures.get(plugin_name.as_str())
11✔
1065
        && sig.return_type == TypeInfo::UInt
11✔
1066
    {
1067
        Some((rule_ids::T001, "true".to_string()))
11✔
1068
    } else {
1069
        None
×
1070
    }
1071
}
332✔
1072

1073
/// Comparison negation: not (.x == 5) → .x != 5
1074
fn suggest_comparison_negation(expr: &str) -> Option<(RuleId, String)> {
327✔
1075
    let expr = expr.trim();
327✔
1076

1077
    let inner = if expr.starts_with("not (") && expr.ends_with(')') {
327✔
1078
        expr[5..expr.len() - 1].trim()
7✔
1079
    } else if expr.starts_with("!(") && expr.ends_with(')') {
320✔
1080
        expr[2..expr.len() - 1].trim()
10✔
1081
    } else {
1082
        return None;
310✔
1083
    };
1084

1085
    negate_comparison_expr(inner).map(|rewritten| (rule_ids::N002, rewritten))
17✔
1086
}
327✔
1087

1088
fn negate_comparison_expr(inner: &str) -> Option<String> {
34✔
1089
    let negations = [
34✔
1090
        (" == ", " != "),
34✔
1091
        (" != ", " == "),
34✔
1092
        (" > ", " <= "),
34✔
1093
        (" < ", " >= "),
34✔
1094
        (" >= ", " < "),
34✔
1095
        (" <= ", " > "),
34✔
1096
    ];
34✔
1097

1098
    for (op, neg_op) in negations {
78✔
1099
        if let Some(op_pos) = inner.find(op) {
78✔
1100
            let left = inner[..op_pos].trim();
28✔
1101
            let right = inner[op_pos + op.len()..].trim();
28✔
1102

1103
            if !left.is_empty() && !right.is_empty() {
28✔
1104
                return Some(format!("{}{}{}", left, neg_op, right));
28✔
1105
            }
×
1106
        }
50✔
1107
    }
1108

1109
    None
6✔
1110
}
34✔
1111

1112
fn rewrite_assertion_expression_with_context(
482✔
1113
    expr: &str,
482✔
1114
    signatures: &HashMap<String, PluginSignature>,
482✔
1115
    bool_plugins: &HashSet<String>,
482✔
1116
    normalization_mode: NormalizationMode,
482✔
1117
) -> Option<(RuleId, String)> {
482✔
1118
    let normalized = normalize_expr_for_optimizer_with_mode(expr, normalization_mode);
482✔
1119
    let expr = normalized.as_ref();
482✔
1120

1121
    if let Some((rule_id, rewrite)) = suggest_boolean_rewrite(expr, bool_plugins) {
482✔
1122
        return Some((rule_id, rewrite));
33✔
1123
    }
449✔
1124

1125
    if let Some((rule_id, rewrite)) = suggest_not_not_rewrite(expr, bool_plugins) {
449✔
1126
        return Some((rule_id, rewrite));
20✔
1127
    }
429✔
1128

1129
    if let Some((rule_id, rewrite)) = suggest_inequality_rewrite(expr, bool_plugins) {
429✔
1130
        return Some((rule_id, rewrite));
14✔
1131
    }
415✔
1132

1133
    if let Some((rule_id, rewrite)) = suggest_double_negation_rewrite(expr, bool_plugins) {
415✔
1134
        return Some((rule_id, rewrite));
3✔
1135
    }
412✔
1136

1137
    if let Some((rule_id, rewrite)) = suggest_operator_canonicalization(expr) {
412✔
1138
        return Some((rule_id, rewrite));
3✔
1139
    }
409✔
1140

1141
    if let Some((rule_id, rewrite)) = suggest_constant_folding(expr) {
409✔
1142
        return Some((rule_id, rewrite));
4✔
1143
    }
405✔
1144

1145
    if let Some((rule_id, rewrite)) = suggest_reflexive_idempotent(expr, signatures) {
405✔
1146
        return Some((rule_id, rewrite));
4✔
1147
    }
401✔
1148

1149
    // Redundant parentheses removal
1150
    if let Some((rule_id, rewrite)) = suggest_redundant_parens(expr) {
401✔
1151
        return Some((rule_id, rewrite));
3✔
1152
    }
398✔
1153

1154
    // If-then-else optimizations
1155
    if let Some((rule_id, rewrite)) = suggest_dead_branch_elimination(expr) {
398✔
1156
        return Some((rule_id, rewrite));
11✔
1157
    }
387✔
1158

1159
    if let Some((rule_id, rewrite)) = suggest_branch_merging(expr) {
387✔
1160
        return Some((rule_id, rewrite));
11✔
1161
    }
376✔
1162

1163
    if let Some((rule_id, rewrite)) = suggest_nested_if_simplification(expr) {
376✔
1164
        return Some((rule_id, rewrite));
×
1165
    }
376✔
1166

1167
    if let Some((rule_id, rewrite)) = suggest_boolean_simplification(expr) {
376✔
1168
        return Some((rule_id, rewrite));
11✔
1169
    }
365✔
1170

1171
    if let Some((rule_id, rewrite)) = suggest_condition_inversion(expr) {
365✔
1172
        return Some((rule_id, rewrite));
11✔
1173
    }
354✔
1174

1175
    // Boolean identity/absorption laws
1176
    if let Some((rule_id, rewrite)) = suggest_boolean_identity_laws(expr) {
354✔
1177
        return Some((rule_id, rewrite));
11✔
1178
    }
343✔
1179

1180
    // Plugin-specific optimizations
1181
    if let Some((rule_id, rewrite)) = suggest_plugin_length_simplification(expr) {
343✔
1182
        return Some((rule_id, rewrite));
11✔
1183
    }
332✔
1184

1185
    // Type-aware optimizations based on TypeInfo
1186
    if let Some((rule_id, rewrite)) = suggest_type_aware_numeric_comparison(expr) {
332✔
1187
        return Some((rule_id, rewrite));
11✔
1188
    }
321✔
1189

1190
    // Comparison negation normalization
1191
    suggest_comparison_negation(expr)
321✔
1192
}
482✔
1193

1194
fn rewrite_assertion_expression_fixed_point_with_mode(
287✔
1195
    expr: &str,
287✔
1196
    mode: NormalizationMode,
287✔
1197
) -> String {
287✔
1198
    let signatures = plugin_signatures();
287✔
1199
    let bool_plugins = boolean_plugins();
287✔
1200

1201
    let mut current = Cow::Borrowed(expr.trim());
287✔
1202
    for _ in 0..32 {
287✔
1203
        let Some((_, rewritten)) =
125✔
1204
            rewrite_assertion_expression_with_context(&current, signatures, bool_plugins, mode)
412✔
1205
        else {
1206
            break;
287✔
1207
        };
1208

1209
        let normalized = rewritten.trim();
125✔
1210
        if normalized == current.as_ref() {
125✔
1211
            break;
×
1212
        }
125✔
1213
        current = Cow::Owned(normalized.to_string());
125✔
1214
    }
1215

1216
    current.into_owned()
287✔
1217
}
287✔
1218

NEW
1219
pub fn rewrite_assertion_expression(expr: &str) -> Option<(&'static str, String)> {
×
NEW
1220
    let signatures = plugin_signatures();
×
NEW
1221
    let bool_plugins = boolean_plugins();
×
NEW
1222
    rewrite_assertion_expression_with_context(expr, signatures, bool_plugins, normalization_mode())
×
NEW
1223
        .map(|(rule_id, rewrite)| (rule_id.as_str(), rewrite))
×
NEW
1224
}
×
1225

1226
pub fn rewrite_assertion_expression_fixed_point(expr: &str) -> String {
95✔
1227
    rewrite_assertion_expression_fixed_point_with_mode(expr, normalization_mode())
95✔
1228
}
95✔
1229

1230
pub fn rewrite_assertion_expression_fixed_point_if_changed(expr: &str) -> Option<String> {
56✔
1231
    let trimmed = expr.trim();
56✔
1232
    if trimmed.is_empty() || !likely_needs_assertion_rewrite(trimmed) {
56✔
1233
        None
11✔
1234
    } else {
1235
        let rewritten = rewrite_assertion_expression_fixed_point(trimmed);
45✔
1236
        if rewritten == trimmed {
45✔
1237
            None
44✔
1238
        } else {
1239
            Some(rewritten)
1✔
1240
        }
1241
    }
1242
}
56✔
1243

1244
pub fn collect_assertion_optimizations(doc: &parser::GctfDocument) -> Vec<OptimizationHint> {
106✔
1245
    let signatures = plugin_signatures();
106✔
1246
    let bool_plugins = boolean_plugins();
106✔
1247
    let mode = normalization_mode();
106✔
1248
    let mut hints = Vec::new();
106✔
1249

1250
    for section in &doc.sections {
313✔
1251
        if section.section_type != parser::ast::SectionType::Asserts {
313✔
1252
            continue;
266✔
1253
        }
47✔
1254

1255
        for (idx, line) in section.raw_content.lines().enumerate() {
69✔
1256
            let Some(trimmed) = strip_assertion_comments(line) else {
69✔
1257
                continue;
1✔
1258
            };
1259

1260
            if !likely_needs_assertion_rewrite(&trimmed) {
68✔
1261
                continue;
×
1262
            }
68✔
1263

1264
            if let Some((rule_id, rewrite)) =
46✔
1265
                rewrite_assertion_expression_with_context(&trimmed, signatures, bool_plugins, mode)
68✔
1266
            {
1267
                debug_assert!(rule_metadata(rule_id).is_some());
46✔
1268
                hints.push(build_hint(
46✔
1269
                    rule_id,
46✔
1270
                    section_content_line(section.start_line, idx),
46✔
1271
                    &trimmed,
46✔
1272
                    rewrite,
46✔
1273
                ));
1274
            }
22✔
1275
        }
1276
    }
1277

1278
    hints
106✔
1279
}
106✔
1280

1281
#[cfg(test)]
1282
mod tests {
1283
    use super::*;
1284

1285
    fn ast_mode_active_for_tests() -> bool {
3✔
1286
        matches!(normalization_mode(), NormalizationMode::AstCanonical)
3✔
1287
    }
3✔
1288

1289
    #[test]
1290
    fn test_collect_assertion_optimizations_detects_boolean_rewrite() {
1✔
1291
        let content = r#"--- ENDPOINT ---
1✔
1292
test.Service/Method
1✔
1293

1✔
1294
--- ASSERTS ---
1✔
1295
@has_header("x-request-id") == true
1✔
1296
"#;
1✔
1297

1298
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1299
        let hints = collect_assertion_optimizations(&doc);
1✔
1300
        assert_eq!(hints.len(), 1);
1✔
1301
        assert_eq!(hints[0].rule_id, rule_ids::B001);
1✔
1302
        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1✔
1303
    }
1✔
1304

1305
    #[test]
1306
    fn test_collect_assertion_optimizations_detects_double_negation_rewrite() {
1✔
1307
        let content = r#"--- ENDPOINT ---
1✔
1308
test.Service/Method
1✔
1309

1✔
1310
--- ASSERTS ---
1✔
1311
!!@has_header("x-request-id")
1✔
1312
"#;
1✔
1313

1314
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1315
        let hints = collect_assertion_optimizations(&doc);
1✔
1316
        assert_eq!(hints.len(), 1);
1✔
1317
        if ast_mode_active_for_tests() {
1✔
1318
            assert_eq!(hints[0].rule_id, rule_ids::B017);
1✔
1319
        } else {
NEW
1320
            assert_eq!(hints[0].rule_id, rule_ids::B005);
×
1321
        }
1322
        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1✔
1323
    }
1✔
1324

1325
    #[test]
1326
    fn test_collect_assertion_optimizations_detects_operator_canonicalization() {
1✔
1327
        let content = r#"--- ENDPOINT ---
1✔
1328
test.Service/Method
1✔
1329

1✔
1330
--- ASSERTS ---
1✔
1331
.name startswith "abc"
1✔
1332
"#;
1✔
1333

1334
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1335
        let hints = collect_assertion_optimizations(&doc);
1✔
1336
        if ast_mode_active_for_tests() {
1✔
1337
            assert!(hints.is_empty());
1✔
1338
        } else {
NEW
1339
            assert_eq!(hints.len(), 1);
×
NEW
1340
            assert_eq!(hints[0].rule_id, rule_ids::N001);
×
NEW
1341
            assert_eq!(hints[0].after, ".name startsWith \"abc\"");
×
1342
        }
1343
    }
1✔
1344

1345
    #[test]
1346
    fn test_collect_assertion_optimizations_no_double_negation_for_non_boolean_plugin() {
1✔
1347
        let content = r#"--- ENDPOINT ---
1✔
1348
test.Service/Method
1✔
1349

1✔
1350
--- ASSERTS ---
1✔
1351
!!@len(.items)
1✔
1352
"#;
1✔
1353

1354
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1355
        let hints = collect_assertion_optimizations(&doc);
1✔
1356
        assert!(hints.is_empty());
1✔
1357
    }
1✔
1358

1359
    #[test]
1360
    fn test_collect_assertion_optimizations_constant_fold_numeric_compare() {
1✔
1361
        let content = r#"--- ENDPOINT ---
1✔
1362
test.Service/Method
1✔
1363

1✔
1364
--- ASSERTS ---
1✔
1365
1 + 1 == 2
1✔
1366
3 > 2
1✔
1367
"#;
1✔
1368

1369
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1370
        let hints = collect_assertion_optimizations(&doc);
1✔
1371

1372
        // Only '3 > 2' is a strict literal compare and safe to fold here.
1373
        assert_eq!(hints.len(), 1);
1✔
1374
        assert_eq!(hints[0].rule_id, rule_ids::B006);
1✔
1375
        assert_eq!(hints[0].before, "3 > 2");
1✔
1376
        assert_eq!(hints[0].after, "true");
1✔
1377
    }
1✔
1378

1379
    #[test]
1380
    fn test_collect_assertion_optimizations_constant_fold_string_equality() {
1✔
1381
        let content = r#"--- ENDPOINT ---
1✔
1382
test.Service/Method
1✔
1383

1✔
1384
--- ASSERTS ---
1✔
1385
"a" == "a"
1✔
1386
"#;
1✔
1387

1388
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1389
        let hints = collect_assertion_optimizations(&doc);
1✔
1390
        assert_eq!(hints.len(), 1);
1✔
1391
        assert_eq!(hints[0].rule_id, rule_ids::B006);
1✔
1392
        assert_eq!(hints[0].after, "true");
1✔
1393
    }
1✔
1394

1395
    #[test]
1396
    fn test_rewrite_rule_metadata_is_complete() {
1✔
1397
        let expected = [
1✔
1398
            rule_ids::B001,
1✔
1399
            rule_ids::B002,
1✔
1400
            rule_ids::B003,
1✔
1401
            rule_ids::B004,
1✔
1402
            rule_ids::B005,
1✔
1403
            rule_ids::B006,
1✔
1404
            rule_ids::B007,
1✔
1405
            rule_ids::B008,
1✔
1406
            rule_ids::B009,
1✔
1407
            rule_ids::B010,
1✔
1408
            rule_ids::B013,
1✔
1409
            rule_ids::B014,
1✔
1410
            rule_ids::B015,
1✔
1411
            rule_ids::B016,
1✔
1412
            rule_ids::B017,
1✔
1413
            rule_ids::N001,
1✔
1414
            rule_ids::N002,
1✔
1415
            rule_ids::I001,
1✔
1416
            rule_ids::I002,
1✔
1417
            rule_ids::I003,
1✔
1418
            rule_ids::I004,
1✔
1419
            rule_ids::I005,
1✔
1420
            rule_ids::P001,
1✔
1421
            rule_ids::P002,
1✔
1422
            rule_ids::T001,
1✔
1423
        ];
1✔
1424

1425
        for id in expected {
25✔
1426
            let meta = rule_metadata(id).unwrap_or_else(|| panic!("missing metadata for {id}"));
25✔
1427
            assert!(!meta.preconditions.is_empty());
25✔
1428
            assert!(!meta.negative_cases.is_empty());
25✔
1429
            assert!(!meta.proof_note.is_empty());
25✔
1430
        }
1431
    }
1✔
1432

1433
    #[test]
1434
    fn test_optimization_hint_contains_rule_metadata() {
1✔
1435
        let content = r#"--- ENDPOINT ---
1✔
1436
test.Service/Method
1✔
1437

1✔
1438
--- ASSERTS ---
1✔
1439
@has_header("x") == true
1✔
1440
"#;
1✔
1441

1442
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1443
        let hints = collect_assertion_optimizations(&doc);
1✔
1444
        assert_eq!(hints.len(), 1);
1✔
1445
        assert!(hints[0].preconditions.as_deref().is_some());
1✔
1446
        assert!(hints[0].negative_cases.as_deref().is_some());
1✔
1447
        assert!(hints[0].proof_note.as_deref().is_some());
1✔
1448
    }
1✔
1449

1450
    #[test]
1451
    fn test_collect_assertion_optimizations_reflexive_idempotent_path() {
1✔
1452
        let content = r#"--- ENDPOINT ---
1✔
1453
test.Service/Method
1✔
1454

1✔
1455
--- ASSERTS ---
1✔
1456
.user.id == .user.id
1✔
1457
"#;
1✔
1458

1459
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1460
        let hints = collect_assertion_optimizations(&doc);
1✔
1461

1462
        assert_eq!(hints.len(), 1);
1✔
1463
        assert_eq!(hints[0].rule_id, rule_ids::B007);
1✔
1464
        assert_eq!(hints[0].after, "true");
1✔
1465
    }
1✔
1466

1467
    #[test]
1468
    fn test_collect_assertion_optimizations_no_reflexive_for_non_idempotent_plugin() {
1✔
1469
        let content = r#"--- ENDPOINT ---
1✔
1470
test.Service/Method
1✔
1471

1✔
1472
--- ASSERTS ---
1✔
1473
@env("HOME") == @env("HOME")
1✔
1474
"#;
1✔
1475

1476
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1477
        let hints = collect_assertion_optimizations(&doc);
1✔
1478

1479
        assert!(hints.is_empty());
1✔
1480
    }
1✔
1481

1482
    #[test]
1483
    fn test_collect_assertion_optimizations_reflexive_idempotent_inequality() {
1✔
1484
        let content = r#"--- ENDPOINT ---
1✔
1485
test.Service/Method
1✔
1486

1✔
1487
--- ASSERTS ---
1✔
1488
{{ user_id }} != {{ user_id }}
1✔
1489
"#;
1✔
1490

1491
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1492
        let hints = collect_assertion_optimizations(&doc);
1✔
1493

1494
        assert_eq!(hints.len(), 1);
1✔
1495
        assert_eq!(hints[0].rule_id, rule_ids::B008);
1✔
1496
        assert_eq!(hints[0].after, "false");
1✔
1497
    }
1✔
1498

1499
    #[test]
1500
    fn test_rewrite_assertion_expression_fixed_point() {
1✔
1501
        let expr = "true == @has_header(\"x-request-id\")";
1✔
1502
        let rewritten = rewrite_assertion_expression_fixed_point(expr);
1✔
1503
        assert_eq!(rewritten, "@has_header(\"x-request-id\")");
1✔
1504
    }
1✔
1505

1506
    #[test]
1507
    fn test_rewrite_assertion_expression_fixed_point_if_changed() {
1✔
1508
        assert_eq!(
1✔
1509
            rewrite_assertion_expression_fixed_point_if_changed(
1✔
1510
                "true == @has_header(\"x-request-id\")"
1✔
1511
            ),
1512
            Some("@has_header(\"x-request-id\")".to_string())
1✔
1513
        );
1514
        assert_eq!(
1✔
1515
            rewrite_assertion_expression_fixed_point_if_changed(".status == 200"),
1✔
1516
            None
1517
        );
1518
    }
1✔
1519

1520
    #[test]
1521
    fn test_collect_assertion_optimizations_ignores_inline_comments() {
1✔
1522
        let content = r#"--- ENDPOINT ---
1✔
1523
test.Service/Method
1✔
1524

1✔
1525
--- ASSERTS ---
1✔
1526
true == @has_header("x-request-id") // comment should be ignored
1✔
1527
"#;
1✔
1528

1529
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1530
        let hints = collect_assertion_optimizations(&doc);
1✔
1531
        assert_eq!(hints.len(), 1);
1✔
1532
        assert_eq!(hints[0].rule_id, rule_ids::B003);
1✔
1533
        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1✔
1534
    }
1✔
1535

1536
    #[test]
1537
    fn test_likely_needs_assertion_rewrite_fast_path() {
1✔
1538
        assert!(!likely_needs_assertion_rewrite("@scope_message_count()"));
1✔
1539
        assert!(likely_needs_assertion_rewrite("@elapsed_ms() >= 10"));
1✔
1540
        assert!(likely_needs_assertion_rewrite("true == @has_header(\"x\")"));
1✔
1541
        assert!(likely_needs_assertion_rewrite(".name startswith \"abc\""));
1✔
1542
        assert!(likely_needs_assertion_rewrite("if true then 1 else 2 end"));
1✔
1543
    }
1✔
1544

1545
    // === If-then-else optimization tests ===
1546

1547
    #[test]
1548
    fn test_dead_branch_elimination_true() {
1✔
1549
        let (rule_id, rewritten) =
1✔
1550
            suggest_dead_branch_elimination("if true then \"yes\" else \"no\" end").unwrap();
1✔
1551
        assert_eq!(rule_id, rule_ids::I001);
1✔
1552
        assert_eq!(rewritten, "\"yes\"");
1✔
1553
    }
1✔
1554

1555
    #[test]
1556
    fn test_dead_branch_elimination_false() {
1✔
1557
        let (rule_id, rewritten) =
1✔
1558
            suggest_dead_branch_elimination("if false then \"yes\" else \"no\" end").unwrap();
1✔
1559
        assert_eq!(rule_id, rule_ids::I001);
1✔
1560
        assert_eq!(rewritten, "\"no\"");
1✔
1561
    }
1✔
1562

1563
    #[test]
1564
    fn test_branch_merging() {
1✔
1565
        let (rule_id, rewritten) =
1✔
1566
            suggest_branch_merging("if .x > 0 then \"same\" else \"same\" end").unwrap();
1✔
1567
        assert_eq!(rule_id, rule_ids::I002);
1✔
1568
        assert_eq!(rewritten, "\"same\"");
1✔
1569
    }
1✔
1570

1571
    #[test]
1572
    fn test_nested_if_simplification() {
1✔
1573
        // Pattern: if A then (if A then X else Y end) else Z end
1574
        // Simplified: if A then X else Z end
1575
        let input =
1✔
1576
            "if .a > 0 then (if .a > 0 then \"inner\" else \"other\" end) else \"outer\" end";
1✔
1577
        let result = suggest_nested_if_simplification(input);
1✔
1578
        assert!(result.is_some());
1✔
1579
        let (rule_id, rewritten) = result.unwrap();
1✔
1580
        assert_eq!(rule_id, rule_ids::I003);
1✔
1581
        assert_eq!(rewritten, "if .a > 0 then \"inner\" else \"outer\" end");
1✔
1582
    }
1✔
1583

1584
    #[test]
1585
    fn test_parse_if_then_else_simple() {
1✔
1586
        let (cond, then_expr, else_expr) =
1✔
1587
            parse_if_then_else("if .x > 0 then \"yes\" else \"no\" end").unwrap();
1✔
1588
        assert_eq!(cond, ".x > 0");
1✔
1589
        assert_eq!(then_expr, "\"yes\"");
1✔
1590
        assert_eq!(else_expr, "\"no\"");
1✔
1591
    }
1✔
1592

1593
    #[test]
1594
    fn test_parse_if_then_else_nested() {
1✔
1595
        let (cond, then_expr, else_expr) = parse_if_then_else(
1✔
1596
            "if .a > 0 then (if .b > 0 then \"both\" else \"a only\" end) else \"none\" end",
1✔
1597
        )
1✔
1598
        .unwrap();
1✔
1599
        assert_eq!(cond, ".a > 0");
1✔
1600
        assert_eq!(then_expr, "(if .b > 0 then \"both\" else \"a only\" end)");
1✔
1601
        assert_eq!(else_expr, "\"none\"");
1✔
1602
    }
1✔
1603

1604
    #[test]
1605
    fn test_collect_optimizations_detects_dead_branch() {
1✔
1606
        let content = r#"--- ENDPOINT ---
1✔
1607
test.Service/Method
1✔
1608

1✔
1609
--- ASSERTS ---
1✔
1610
if true then "always" else "never" end
1✔
1611
"#;
1✔
1612

1613
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1614
        let hints = collect_assertion_optimizations(&doc);
1✔
1615
        assert_eq!(hints.len(), 1);
1✔
1616
        assert_eq!(hints[0].rule_id, rule_ids::I001);
1✔
1617
        assert_eq!(hints[0].after, "\"always\"");
1✔
1618
    }
1✔
1619

1620
    #[test]
1621
    fn test_collect_optimizations_detects_branch_merging() {
1✔
1622
        let content = r#"--- ENDPOINT ---
1✔
1623
test.Service/Method
1✔
1624

1✔
1625
--- ASSERTS ---
1✔
1626
if .x > 0 then "same" else "same" end
1✔
1627
"#;
1✔
1628

1629
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1630
        let hints = collect_assertion_optimizations(&doc);
1✔
1631
        assert_eq!(hints.len(), 1);
1✔
1632
        assert_eq!(hints[0].rule_id, rule_ids::I002);
1✔
1633
        assert_eq!(hints[0].after, "\"same\"");
1✔
1634
    }
1✔
1635

1636
    #[test]
1637
    fn test_boolean_simplification() {
1✔
1638
        let (rule_id, rewritten) =
1✔
1639
            suggest_boolean_simplification("if .x > 0 then true else false end").unwrap();
1✔
1640
        assert_eq!(rule_id, rule_ids::I004);
1✔
1641
        assert_eq!(rewritten, ".x > 0");
1✔
1642
    }
1✔
1643

1644
    #[test]
1645
    fn test_condition_inversion() {
1✔
1646
        let (rule_id, rewritten) =
1✔
1647
            suggest_condition_inversion("if .x > 0 then false else true end").unwrap();
1✔
1648
        assert_eq!(rule_id, rule_ids::I005);
1✔
1649
        assert_eq!(rewritten, ".x <= 0");
1✔
1650
    }
1✔
1651

1652
    #[test]
1653
    fn test_condition_inversion_contains_needs_parens() {
1✔
1654
        let (rule_id, rewritten) =
1✔
1655
            suggest_condition_inversion("if .name contains \"foo\" then false else true end")
1✔
1656
                .unwrap();
1✔
1657
        assert_eq!(rule_id, rule_ids::I005);
1✔
1658
        assert_eq!(rewritten, "!(.name contains \"foo\")");
1✔
1659
    }
1✔
1660

1661
    #[test]
1662
    fn test_condition_inversion_simple_plugin_call_no_parens() {
1✔
1663
        let (rule_id, rewritten) =
1✔
1664
            suggest_condition_inversion("if @has_header(\"x\") then false else true end").unwrap();
1✔
1665
        assert_eq!(rule_id, rule_ids::I005);
1✔
1666
        assert_eq!(rewritten, "!@has_header(\"x\")");
1✔
1667
    }
1✔
1668

1669
    #[test]
1670
    fn test_condition_inversion_not_keyword_gets_grouped() {
1✔
1671
        let (rule_id, rewritten) =
1✔
1672
            suggest_condition_inversion("if not @has_header(\"x\") then false else true end")
1✔
1673
                .unwrap();
1✔
1674
        assert_eq!(rule_id, rule_ids::I005);
1✔
1675
        assert_eq!(rewritten, "!(not @has_header(\"x\"))");
1✔
1676
    }
1✔
1677

1678
    #[test]
1679
    fn test_condition_inversion_bang_gets_grouped() {
1✔
1680
        let (rule_id, rewritten) =
1✔
1681
            suggest_condition_inversion("if !@has_header(\"x\") then false else true end").unwrap();
1✔
1682
        assert_eq!(rule_id, rule_ids::I005);
1✔
1683
        assert_eq!(rewritten, "!(!@has_header(\"x\"))");
1✔
1684
    }
1✔
1685

1686
    #[test]
1687
    fn test_condition_inversion_matches_gets_grouped() {
1✔
1688
        let (rule_id, rewritten) =
1✔
1689
            suggest_condition_inversion("if .name matches /foo.*/ then false else true end")
1✔
1690
                .unwrap();
1✔
1691
        assert_eq!(rule_id, rule_ids::I005);
1✔
1692
        assert_eq!(rewritten, "!(.name matches /foo.*/)");
1✔
1693
    }
1✔
1694

1695
    #[test]
1696
    fn test_collect_optimizations_boolean_simplification() {
1✔
1697
        let content = r#"--- ENDPOINT ---
1✔
1698
test.Service/Method
1✔
1699

1✔
1700
--- ASSERTS ---
1✔
1701
if @has_header("x") then true else false end
1✔
1702
"#;
1✔
1703

1704
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1705
        let hints = collect_assertion_optimizations(&doc);
1✔
1706
        assert_eq!(hints.len(), 1);
1✔
1707
        assert_eq!(hints[0].rule_id, rule_ids::I004);
1✔
1708
        assert_eq!(hints[0].after, "@has_header(\"x\")");
1✔
1709
    }
1✔
1710

1711
    #[test]
1712
    fn test_collect_optimizations_condition_inversion() {
1✔
1713
        let content = r#"--- ENDPOINT ---
1✔
1714
test.Service/Method
1✔
1715

1✔
1716
--- ASSERTS ---
1✔
1717
if .status == 200 then false else true end
1✔
1718
"#;
1✔
1719

1720
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1721
        let hints = collect_assertion_optimizations(&doc);
1✔
1722
        assert_eq!(hints.len(), 1);
1✔
1723
        assert_eq!(hints[0].rule_id, rule_ids::I005);
1✔
1724
        assert_eq!(hints[0].after, ".status != 200");
1✔
1725
    }
1✔
1726

1727
    #[test]
1728
    fn test_parse_if_then_else_string_with_else_keyword() {
1✔
1729
        let (cond, then_expr, else_expr) =
1✔
1730
            parse_if_then_else(r#"if true then " else " else "no" end"#).unwrap();
1✔
1731
        assert_eq!(cond, "true");
1✔
1732
        assert_eq!(then_expr, r#"" else ""#);
1✔
1733
        assert_eq!(else_expr, r#""no""#);
1✔
1734
    }
1✔
1735

1736
    #[test]
1737
    fn test_parse_if_then_else_then_in_string_condition() {
1✔
1738
        let (cond, then_expr, else_expr) =
1✔
1739
            parse_if_then_else(r#"if .x == "then" then "yes" else "no" end"#).unwrap();
1✔
1740
        assert_eq!(cond, r#".x == "then""#);
1✔
1741
        assert_eq!(then_expr, r#""yes""#);
1✔
1742
        assert_eq!(else_expr, r#""no""#);
1✔
1743
    }
1✔
1744

1745
    // === New optimization rules tests ===
1746

1747
    #[test]
1748
    fn test_boolean_identity_or() {
1✔
1749
        // A or true = true
1750
        let (rule_id, rewritten) = suggest_boolean_identity_laws(".x or true").unwrap();
1✔
1751
        assert_eq!(rule_id, rule_ids::B009);
1✔
1752
        assert_eq!(rewritten, "true");
1✔
1753

1754
        // A or false = A
1755
        let (rule_id, rewritten) = suggest_boolean_identity_laws(".x or false").unwrap();
1✔
1756
        assert_eq!(rule_id, rule_ids::B009);
1✔
1757
        assert_eq!(rewritten, ".x");
1✔
1758

1759
        // true or A = true
1760
        let (rule_id, rewritten) = suggest_boolean_identity_laws("true or .x").unwrap();
1✔
1761
        assert_eq!(rule_id, rule_ids::B009);
1✔
1762
        assert_eq!(rewritten, "true");
1✔
1763
    }
1✔
1764

1765
    #[test]
1766
    fn test_boolean_absorption_and() {
1✔
1767
        // A and true = A
1768
        let (rule_id, rewritten) = suggest_boolean_identity_laws(".x and true").unwrap();
1✔
1769
        assert_eq!(rule_id, rule_ids::B010);
1✔
1770
        assert_eq!(rewritten, ".x");
1✔
1771

1772
        // A and false = false
1773
        let (rule_id, rewritten) = suggest_boolean_identity_laws(".x and false").unwrap();
1✔
1774
        assert_eq!(rule_id, rule_ids::B010);
1✔
1775
        assert_eq!(rewritten, "false");
1✔
1776

1777
        // false and A = false
1778
        let (rule_id, rewritten) = suggest_boolean_identity_laws("false and .x").unwrap();
1✔
1779
        assert_eq!(rule_id, rule_ids::B010);
1✔
1780
        assert_eq!(rewritten, "false");
1✔
1781
    }
1✔
1782

1783
    #[test]
1784
    fn test_plugin_length_simplification() {
1✔
1785
        // @len(.x) == 0 → @empty(.x)
1786
        let (rule_id, rewritten) =
1✔
1787
            suggest_plugin_length_simplification("@len(.items) == 0").unwrap();
1✔
1788
        assert_eq!(rule_id, rule_ids::P001);
1✔
1789
        assert_eq!(rewritten, "@empty(.items)");
1✔
1790

1791
        // @len(.x) != 0 → @len(.x) > 0
1792
        let (rule_id, rewritten) =
1✔
1793
            suggest_plugin_length_simplification("@len(.items) != 0").unwrap();
1✔
1794
        assert_eq!(rule_id, rule_ids::P001);
1✔
1795
        assert_eq!(rewritten, "@len(.items) > 0");
1✔
1796

1797
        // @len(.x) > 0 → no simplification
1798
        let result = suggest_plugin_length_simplification("@len(.items) > 0");
1✔
1799
        assert!(result.is_none());
1✔
1800

1801
        // 0 == @len(.x) → @empty(.x)
1802
        let (rule_id, rewritten) =
1✔
1803
            suggest_plugin_length_simplification("0 == @len(.items)").unwrap();
1✔
1804
        assert_eq!(rule_id, rule_ids::P001);
1✔
1805
        assert_eq!(rewritten, "@empty(.items)");
1✔
1806
    }
1✔
1807

1808
    #[test]
1809
    fn test_comparison_negation() {
1✔
1810
        // not (.x == 5) → .x != 5
1811
        let (rule_id, rewritten) = suggest_comparison_negation("not (.x == 5)").unwrap();
1✔
1812
        assert_eq!(rule_id, rule_ids::N002);
1✔
1813
        assert_eq!(rewritten, ".x != 5");
1✔
1814

1815
        // not (.x != 5) → .x == 5
1816
        let (rule_id, rewritten) = suggest_comparison_negation("not (.x != 5)").unwrap();
1✔
1817
        assert_eq!(rule_id, rule_ids::N002);
1✔
1818
        assert_eq!(rewritten, ".x == 5");
1✔
1819

1820
        // not (.x > 5) → .x <= 5
1821
        let (rule_id, rewritten) = suggest_comparison_negation("not (.x > 5)").unwrap();
1✔
1822
        assert_eq!(rule_id, rule_ids::N002);
1✔
1823
        assert_eq!(rewritten, ".x <= 5");
1✔
1824

1825
        // not (.x >= 5) → .x < 5
1826
        let (rule_id, rewritten) = suggest_comparison_negation("not (.x >= 5)").unwrap();
1✔
1827
        assert_eq!(rule_id, rule_ids::N002);
1✔
1828
        assert_eq!(rewritten, ".x < 5");
1✔
1829

1830
        // !(.x <= 5) -> .x > 5
1831
        let (rule_id, rewritten) = suggest_comparison_negation("!(.x <= 5)").unwrap();
1✔
1832
        assert_eq!(rule_id, rule_ids::N002);
1✔
1833
        assert_eq!(rewritten, ".x > 5");
1✔
1834

1835
        // malformed/non-comparison inner should not rewrite
1836
        assert!(suggest_comparison_negation("!(.x)").is_none());
1✔
1837
    }
1✔
1838

1839
    #[test]
1840
    fn test_collect_optimizations_boolean_identity() {
1✔
1841
        let content = r#"--- ENDPOINT ---
1✔
1842
test.Service/Method
1✔
1843

1✔
1844
--- ASSERTS ---
1✔
1845
@has_header("x") or true
1✔
1846
"#;
1✔
1847

1848
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1849
        let hints = collect_assertion_optimizations(&doc);
1✔
1850
        assert_eq!(hints.len(), 1);
1✔
1851
        assert_eq!(hints[0].rule_id, rule_ids::B009);
1✔
1852
        assert_eq!(hints[0].after, "true");
1✔
1853
    }
1✔
1854

1855
    #[test]
1856
    fn test_collect_optimizations_plugin_length() {
1✔
1857
        let content = r#"--- ENDPOINT ---
1✔
1858
test.Service/Method
1✔
1859

1✔
1860
--- ASSERTS ---
1✔
1861
@len(.items) == 0
1✔
1862
"#;
1✔
1863

1864
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1865
        let hints = collect_assertion_optimizations(&doc);
1✔
1866
        assert_eq!(hints.len(), 1);
1✔
1867
        assert_eq!(hints[0].rule_id, rule_ids::P001);
1✔
1868
        assert_eq!(hints[0].after, "@empty(.items)");
1✔
1869
    }
1✔
1870

1871
    #[test]
1872
    fn test_collect_optimizations_type_aware_uint_gte_zero() {
1✔
1873
        let content = r#"--- ENDPOINT ---
1✔
1874
test.Service/Method
1✔
1875

1✔
1876
--- ASSERTS ---
1✔
1877
@len(.items) >= 0
1✔
1878
"#;
1✔
1879

1880
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1881
        let hints = collect_assertion_optimizations(&doc);
1✔
1882
        assert_eq!(hints.len(), 1);
1✔
1883
        assert_eq!(hints[0].rule_id, rule_ids::T001);
1✔
1884
        assert_eq!(hints[0].after, "true");
1✔
1885
    }
1✔
1886

1887
    #[test]
1888
    fn test_collect_optimizations_comparison_negation() {
1✔
1889
        let content = r#"--- ENDPOINT ---
1✔
1890
test.Service/Method
1✔
1891

1✔
1892
--- ASSERTS ---
1✔
1893
not (.status == 200)
1✔
1894
"#;
1✔
1895

1896
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1897
        let hints = collect_assertion_optimizations(&doc);
1✔
1898
        assert_eq!(hints.len(), 1);
1✔
1899
        assert_eq!(hints[0].rule_id, rule_ids::N002);
1✔
1900
        assert_eq!(hints[0].after, ".status != 200");
1✔
1901
    }
1✔
1902

1903
    #[test]
1904
    fn test_collect_optimizations_b002_expr_equals_false() {
1✔
1905
        let content = r#"--- ENDPOINT ---
1✔
1906
test.Service/Method
1✔
1907

1✔
1908
--- ASSERTS ---
1✔
1909
@has_header("x") == false
1✔
1910
"#;
1✔
1911
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1912
        let hints = collect_assertion_optimizations(&doc);
1✔
1913
        assert_eq!(hints.len(), 1);
1✔
1914
        assert_eq!(hints[0].rule_id, rule_ids::B002);
1✔
1915
        assert_eq!(hints[0].after, "!@has_header(\"x\")");
1✔
1916
    }
1✔
1917

1918
    #[test]
1919
    fn test_collect_optimizations_b004_false_equals_expr() {
1✔
1920
        let content = r#"--- ENDPOINT ---
1✔
1921
test.Service/Method
1✔
1922

1✔
1923
--- ASSERTS ---
1✔
1924
false == @has_header("x")
1✔
1925
"#;
1✔
1926
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1927
        let hints = collect_assertion_optimizations(&doc);
1✔
1928
        assert_eq!(hints.len(), 1);
1✔
1929
        assert_eq!(hints[0].rule_id, rule_ids::B004);
1✔
1930
        assert_eq!(hints[0].after, "!@has_header(\"x\")");
1✔
1931
    }
1✔
1932

1933
    #[test]
1934
    fn test_collect_optimizations_b013_inequality_true() {
1✔
1935
        let content = r#"--- ENDPOINT ---
1✔
1936
test.Service/Method
1✔
1937

1✔
1938
--- ASSERTS ---
1✔
1939
@has_header("x") != true
1✔
1940
"#;
1✔
1941
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1942
        let hints = collect_assertion_optimizations(&doc);
1✔
1943
        assert_eq!(hints.len(), 1);
1✔
1944
        assert_eq!(hints[0].rule_id, rule_ids::B013);
1✔
1945
        assert_eq!(hints[0].after, "!@has_header(\"x\")");
1✔
1946
    }
1✔
1947

1948
    #[test]
1949
    fn test_collect_optimizations_b014_inequality_false() {
1✔
1950
        let content = r#"--- ENDPOINT ---
1✔
1951
test.Service/Method
1✔
1952

1✔
1953
--- ASSERTS ---
1✔
1954
@has_header("x") != false
1✔
1955
"#;
1✔
1956
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1957
        let hints = collect_assertion_optimizations(&doc);
1✔
1958
        assert_eq!(hints.len(), 1);
1✔
1959
        assert_eq!(hints[0].rule_id, rule_ids::B014);
1✔
1960
        assert_eq!(hints[0].after, "@has_header(\"x\")");
1✔
1961
    }
1✔
1962

1963
    #[test]
1964
    fn test_collect_optimizations_b015_true_inequality() {
1✔
1965
        let content = r#"--- ENDPOINT ---
1✔
1966
test.Service/Method
1✔
1967

1✔
1968
--- ASSERTS ---
1✔
1969
true != @has_header("x")
1✔
1970
"#;
1✔
1971
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1972
        let hints = collect_assertion_optimizations(&doc);
1✔
1973
        assert_eq!(hints.len(), 1);
1✔
1974
        assert_eq!(hints[0].rule_id, rule_ids::B015);
1✔
1975
        assert_eq!(hints[0].after, "!@has_header(\"x\")");
1✔
1976
    }
1✔
1977

1978
    #[test]
1979
    fn test_collect_optimizations_b016_false_inequality() {
1✔
1980
        let content = r#"--- ENDPOINT ---
1✔
1981
test.Service/Method
1✔
1982

1✔
1983
--- ASSERTS ---
1✔
1984
false != @has_header("x")
1✔
1985
"#;
1✔
1986
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
1987
        let hints = collect_assertion_optimizations(&doc);
1✔
1988
        assert_eq!(hints.len(), 1);
1✔
1989
        assert_eq!(hints[0].rule_id, rule_ids::B016);
1✔
1990
        assert_eq!(hints[0].after, "@has_header(\"x\")");
1✔
1991
    }
1✔
1992

1993
    #[test]
1994
    fn test_collect_optimizations_b017_double_not_word() {
1✔
1995
        let content = r#"--- ENDPOINT ---
1✔
1996
test.Service/Method
1✔
1997

1✔
1998
--- ASSERTS ---
1✔
1999
not not @has_header("x")
1✔
2000
"#;
1✔
2001
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
2002
        let hints = collect_assertion_optimizations(&doc);
1✔
2003
        assert_eq!(hints.len(), 1);
1✔
2004
        assert_eq!(hints[0].rule_id, rule_ids::B017);
1✔
2005
        assert_eq!(hints[0].after, "@has_header(\"x\")");
1✔
2006
    }
1✔
2007

2008
    #[test]
2009
    fn test_collect_optimizations_p002_redundant_parens() {
1✔
2010
        let result = rewrite_assertion_expression_fixed_point("(@has_header(\"x\"))");
1✔
2011
        if ast_mode_active_for_tests() {
1✔
2012
            assert_eq!(result, "(@has_header(\"x\"))");
1✔
2013
        } else {
NEW
2014
            assert_eq!(result, "@has_header(\"x\")");
×
2015
        }
2016
    }
1✔
2017

2018
    #[test]
2019
    fn test_boolean_plugins_contains_uuid() {
1✔
2020
        let bp = boolean_plugins();
1✔
2021
        assert!(bp.contains("uuid"));
1✔
2022
        assert!(bp.contains("email"));
1✔
2023
        assert!(bp.contains("empty"));
1✔
2024
    }
1✔
2025

2026
    #[test]
2027
    fn test_plugin_signatures_returns_map() {
1✔
2028
        let sigs = plugin_signatures();
1✔
2029
        assert!(!sigs.is_empty());
1✔
2030
        assert!(sigs.contains_key("uuid"));
1✔
2031
    }
1✔
2032

2033
    #[test]
2034
    fn test_is_boolean_plugin_expr() {
1✔
2035
        let bp = boolean_plugins();
1✔
2036
        assert!(is_boolean_plugin_expr("@uuid(.x)", bp));
1✔
2037
        assert!(is_boolean_plugin_expr("@empty(.items)", bp));
1✔
2038
        assert!(!is_boolean_plugin_expr("@len(.x)", bp));
1✔
2039
    }
1✔
2040

2041
    #[test]
2042
    fn test_suggest_constant_folding_string_equality() {
1✔
2043
        let result = suggest_constant_folding("\"foo\" == \"foo\"");
1✔
2044
        assert!(result.is_some());
1✔
2045
        let (rule_id, after) = result.unwrap();
1✔
2046
        assert_eq!(rule_id, rule_ids::B006);
1✔
2047
        assert_eq!(after, "true");
1✔
2048
    }
1✔
2049

2050
    #[test]
2051
    fn test_suggest_constant_folding_mixed_types() {
1✔
2052
        let result = suggest_constant_folding("\"foo\" == 123");
1✔
2053
        assert!(result.is_some());
1✔
2054
        let (_rule_id, after) = result.unwrap();
1✔
2055
        assert_eq!(after, "false");
1✔
2056
    }
1✔
2057

2058
    #[test]
2059
    fn test_suggest_constant_folding_invalid_json() {
1✔
2060
        let result = suggest_constant_folding("@len(.x) == 5");
1✔
2061
        assert!(result.is_none());
1✔
2062
    }
1✔
2063

2064
    #[test]
2065
    fn test_normalization_mode_is_ast_canonical() {
1✔
2066
        assert_eq!(normalization_mode(), NormalizationMode::AstCanonical);
1✔
2067
    }
1✔
2068

2069
    #[test]
2070
    fn test_ast_mode_can_change_first_matching_rule() {
1✔
2071
        let signatures = plugin_signatures();
1✔
2072
        let bool_plugins = boolean_plugins();
1✔
2073
        let expr = "((@has_header(\"x\"))) == true";
1✔
2074

2075
        let conservative = rewrite_assertion_expression_with_context(
1✔
2076
            expr,
1✔
2077
            signatures,
1✔
2078
            bool_plugins,
1✔
2079
            NormalizationMode::Conservative,
1✔
2080
        );
2081
        let ast = rewrite_assertion_expression_with_context(
1✔
2082
            expr,
1✔
2083
            signatures,
1✔
2084
            bool_plugins,
1✔
2085
            NormalizationMode::AstCanonical,
1✔
2086
        );
2087

2088
        assert_eq!(conservative.map(|(id, _)| id), None);
1✔
2089
        assert_eq!(ast.map(|(id, _)| id), Some(rule_ids::B001));
1✔
2090
    }
1✔
2091

2092
    #[test]
2093
    fn test_ast_canonical_mode_preserves_execution_result() {
1✔
2094
        use crate::assert::engine::{AssertionEngine, AssertionResult};
2095
        use serde_json::json;
2096

2097
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2098
        enum Outcome {
2099
            Pass,
2100
            Fail,
2101
            Error,
2102
        }
2103

2104
        fn outcome_of(result: &AssertionResult) -> Outcome {
144✔
2105
            match result {
144✔
2106
                AssertionResult::Pass => Outcome::Pass,
81✔
2107
                AssertionResult::Fail { .. } => Outcome::Fail,
63✔
NEW
2108
                AssertionResult::Error(_) => Outcome::Error,
×
2109
            }
2110
        }
144✔
2111

2112
        let engine = AssertionEngine::new();
1✔
2113
        let cases = [
1✔
2114
            "!!@has_header(\"x\")",
1✔
2115
            "not not @has_header(\"x\")",
1✔
2116
            "@has_header(\"x\") == true",
1✔
2117
            "@has_header(\"x\") == false",
1✔
2118
            "true != @has_header(\"x\")",
1✔
2119
            ".name startswith \"abc\"",
1✔
2120
            "not (.status == 200)",
1✔
2121
            "if @has_header(\"x\") then true else false end",
1✔
2122
            "if .status == 200 then false else true end",
1✔
2123
            "if true then \"always\" else \"never\" end",
1✔
2124
            "if .x > 0 then \"same\" else \"same\" end",
1✔
2125
            "(@has_header(\"x\"))",
1✔
2126
            "@len(.items) >= 0",
1✔
2127
            "@len(.items) == 0",
1✔
2128
            "@has_header(\"x\") == true and .status == 200",
1✔
2129
            "true or @has_header(\"x\")",
1✔
2130
        ];
1✔
2131

2132
        let contexts = vec![
1✔
2133
            (
1✔
2134
                "status_200_with_header",
1✔
2135
                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2] }),
1✔
2136
                Some(std::collections::HashMap::from([(
1✔
2137
                    "x".to_string(),
1✔
2138
                    "1".to_string(),
1✔
2139
                )])),
1✔
2140
            ),
1✔
2141
            (
1✔
2142
                "status_200_without_header",
1✔
2143
                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2] }),
1✔
2144
                None,
1✔
2145
            ),
1✔
2146
            (
1✔
2147
                "status_500_without_header",
1✔
2148
                json!({ "status": 500, "name": "zzz", "x": 0, "items": [] }),
1✔
2149
                None,
1✔
2150
            ),
1✔
2151
        ];
2152

2153
        for (ctx_name, response, headers_owned) in contexts {
3✔
2154
            let headers_ref = headers_owned.as_ref();
3✔
2155
            for expr in cases {
48✔
2156
                let conservative = rewrite_assertion_expression_fixed_point_with_mode(
48✔
2157
                    expr,
48✔
2158
                    NormalizationMode::Conservative,
48✔
2159
                );
2160
                let ast = rewrite_assertion_expression_fixed_point_with_mode(
48✔
2161
                    expr,
48✔
2162
                    NormalizationMode::AstCanonical,
48✔
2163
                );
2164

2165
                let before = engine.evaluate(expr, &response, headers_ref, None).unwrap();
48✔
2166
                let after_conservative = engine
48✔
2167
                    .evaluate(&conservative, &response, headers_ref, None)
48✔
2168
                    .unwrap();
48✔
2169
                let after_ast = engine.evaluate(&ast, &response, headers_ref, None).unwrap();
48✔
2170

2171
                let before_outcome = outcome_of(&before);
48✔
2172
                let conservative_outcome = outcome_of(&after_conservative);
48✔
2173
                let ast_outcome = outcome_of(&after_ast);
48✔
2174

2175
                assert_eq!(
48✔
2176
                    before_outcome, conservative_outcome,
2177
                    "conservative rewrite changed outcome in {ctx_name}: {expr} -> {conservative}",
2178
                );
2179
                assert_eq!(
48✔
2180
                    before_outcome, ast_outcome,
2181
                    "ast rewrite changed outcome in {ctx_name}: {expr} -> {ast}",
2182
                );
2183

2184
                let conservative_twice = rewrite_assertion_expression_fixed_point_with_mode(
48✔
2185
                    &conservative,
48✔
2186
                    NormalizationMode::Conservative,
48✔
2187
                );
2188
                let ast_twice = rewrite_assertion_expression_fixed_point_with_mode(
48✔
2189
                    &ast,
48✔
2190
                    NormalizationMode::AstCanonical,
48✔
2191
                );
2192
                assert_eq!(
48✔
2193
                    conservative, conservative_twice,
2194
                    "conservative rewrite not idempotent in {ctx_name}: {expr}",
2195
                );
2196
                assert_eq!(
48✔
2197
                    ast, ast_twice,
2198
                    "ast rewrite not idempotent in {ctx_name}: {expr}",
2199
                );
2200

2201
                let default_path = rewrite_assertion_expression_fixed_point(expr);
48✔
2202
                assert_eq!(
48✔
2203
                    default_path, ast,
2204
                    "default rewrite diverged from ast mode in {ctx_name}: {expr}",
2205
                );
2206
            }
2207
        }
2208
    }
1✔
2209

2210
    #[test]
2211
    fn test_optimizer_hints_preserve_execution_result() {
1✔
2212
        use crate::assert::engine::{AssertionEngine, AssertionResult};
2213
        use serde_json::json;
2214

2215
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2216
        enum Outcome {
2217
            Pass,
2218
            Fail,
2219
            Error,
2220
        }
2221

2222
        fn outcome_of(result: &AssertionResult) -> Outcome {
102✔
2223
            match result {
102✔
2224
                AssertionResult::Pass => Outcome::Pass,
62✔
2225
                AssertionResult::Fail { .. } => Outcome::Fail,
40✔
NEW
2226
                AssertionResult::Error(_) => Outcome::Error,
×
2227
            }
2228
        }
102✔
2229

2230
        let content = r#"--- ENDPOINT ---
1✔
2231
test.Service/Method
1✔
2232

1✔
2233
--- ASSERTS ---
1✔
2234
@has_header("x") == true
1✔
2235
@has_header("x") == false
1✔
2236
false == @has_header("x")
1✔
2237
@has_header("x") != true
1✔
2238
!!@has_header("x")
1✔
2239
not not @has_header("x")
1✔
2240
.name startswith "abc"
1✔
2241
3 > 2
1✔
2242
.user.id == .user.id
1✔
2243
{{ user_id }} != {{ user_id }}
1✔
2244
if true then "always" else "never" end
1✔
2245
if .x > 0 then "same" else "same" end
1✔
2246
if @has_header("x") then true else false end
1✔
2247
if .status == 200 then false else true end
1✔
2248
@len(.items) == 0
1✔
2249
(@has_header("x"))
1✔
2250
not (.status == 200)
1✔
2251
@len(.items) >= 0
1✔
2252
@has_header("x") or true
1✔
2253
"#;
1✔
2254

2255
        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1✔
2256
        let hints = collect_assertion_optimizations(&doc);
1✔
2257
        assert!(!hints.is_empty());
1✔
2258

2259
        let engine = AssertionEngine::new();
1✔
2260
        let contexts = vec![
1✔
2261
            (
1✔
2262
                "status_200_with_header",
1✔
2263
                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2], "user": { "id": 1 } }),
1✔
2264
                Some(std::collections::HashMap::from([(
1✔
2265
                    "x".to_string(),
1✔
2266
                    "1".to_string(),
1✔
2267
                )])),
1✔
2268
            ),
1✔
2269
            (
1✔
2270
                "status_200_without_header",
1✔
2271
                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2], "user": { "id": 1 } }),
1✔
2272
                None,
1✔
2273
            ),
1✔
2274
            (
1✔
2275
                "status_500_without_header",
1✔
2276
                json!({ "status": 500, "name": "zzz", "x": 0, "items": [], "user": { "id": 1 } }),
1✔
2277
                None,
1✔
2278
            ),
1✔
2279
        ];
2280

2281
        for hint in hints {
17✔
2282
            for (ctx_name, response, headers_owned) in &contexts {
51✔
2283
                let headers_ref = headers_owned.as_ref();
51✔
2284
                let before = engine
51✔
2285
                    .evaluate(&hint.before, response, headers_ref, None)
51✔
2286
                    .unwrap();
51✔
2287
                let after = engine
51✔
2288
                    .evaluate(&hint.after, response, headers_ref, None)
51✔
2289
                    .unwrap();
51✔
2290

2291
                assert_eq!(
51✔
2292
                    outcome_of(&before),
51✔
2293
                    outcome_of(&after),
51✔
2294
                    "rule {} changed outcome in {ctx_name}: '{}' -> '{}'",
2295
                    hint.rule_id,
2296
                    hint.before,
2297
                    hint.after,
2298
                );
2299
            }
2300
        }
2301
    }
1✔
2302
}
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