Skip to main content

apif_optimizer/
lib.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::borrow::Cow;
3use std::collections::{HashMap, HashSet};
4use std::sync::LazyLock;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum OptimizeLevel {
8    None = 0,
9    Safe = 1,
10    Advisory = 2,
11    Aggressive = 3,
12}
13
14impl OptimizeLevel {
15    #[must_use]
16    pub fn is_enabled(self, rule_level: OptimizeLevel) -> bool {
17        self as u8 >= rule_level as u8
18    }
19}
20
21use apif_parser as parser;
22use apif_parser::assertions::strip_assertion_comments;
23use apif_plugins::{PluginSignature, TypeInfo, extract_plugin_call_name};
24use apif_utils::section_content_line;
25
26fn likely_needs_assertion_rewrite(expr: &str) -> bool {
27    expr.contains("==")
28        || expr.contains("!=")
29        || expr.contains('>')
30        || expr.contains('<')
31        || expr.contains('@')
32        || expr.contains(" startswith ")
33        || expr.contains(" endswith ")
34        || expr.contains("!!")
35        || expr.contains("not not ")
36        || expr.contains("if ")
37        || expr.contains(" then ")
38        || expr.contains(" else ")
39        || expr.contains(" or ")
40        || expr.contains(" and ")
41        || expr.contains("@len(")
42        || expr.contains(">= 0")
43        || expr.contains("<= @")
44        || expr.starts_with('(')
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum NormalizationMode {
49    #[cfg(test)]
50    Conservative,
51    AstCanonical,
52}
53
54fn normalization_mode() -> NormalizationMode {
55    NormalizationMode::AstCanonical
56}
57
58fn normalize_expr_for_optimizer_with_mode<'a>(
59    expr: &'a str,
60    mode: NormalizationMode,
61) -> Cow<'a, str> {
62    let trimmed = expr.trim();
63    match mode {
64        #[cfg(test)]
65        NormalizationMode::Conservative => Cow::Borrowed(trimmed),
66        NormalizationMode::AstCanonical => canonicalize_expr_with_ast(trimmed)
67            .map(Cow::Owned)
68            .unwrap_or_else(|| Cow::Borrowed(trimmed)),
69    }
70}
71
72fn canonicalize_expr_with_ast(expr: &str) -> Option<String> {
73    use apif_parser::assertion_ast::AssertionExpr;
74
75    fn ast_to_if_string(expr: &AssertionExpr, out: &mut String, prec: u8) {
76        match expr {
77            AssertionExpr::Or { left, right } => {
78                if prec > 1 {
79                    out.push('(');
80                }
81                ast_to_if_string(left, out, 1);
82                out.push_str(" or ");
83                ast_to_if_string(right, out, 1);
84                if prec > 1 {
85                    out.push(')');
86                }
87            }
88            AssertionExpr::Xor { left, right } => {
89                if prec > 1 {
90                    out.push('(');
91                }
92                ast_to_if_string(left, out, 1);
93                out.push_str(" xor ");
94                ast_to_if_string(right, out, 1);
95                if prec > 1 {
96                    out.push(')');
97                }
98            }
99            AssertionExpr::And { left, right } => {
100                if prec > 2 {
101                    out.push('(');
102                }
103                ast_to_if_string(left, out, 2);
104                out.push_str(" and ");
105                ast_to_if_string(right, out, 2);
106                if prec > 2 {
107                    out.push(')');
108                }
109            }
110            AssertionExpr::Binary { op, left, right } => {
111                if prec > 3 {
112                    out.push('(');
113                }
114                ast_to_if_string(left, out, 3);
115                out.push(' ');
116                out.push_str(op.as_str());
117                out.push(' ');
118                ast_to_if_string(right, out, 3);
119                if prec > 3 {
120                    out.push(')');
121                }
122            }
123            AssertionExpr::Not(inner) => {
124                out.push('!');
125                ast_to_if_string(inner, out, 4);
126            }
127            AssertionExpr::NotNot(inner) => {
128                out.push_str("not not ");
129                ast_to_if_string(inner, out, 4);
130            }
131            AssertionExpr::IfThenElse {
132                condition,
133                then_branch,
134                else_branch,
135            } => {
136                out.push_str("if ");
137                ast_to_if_string(condition, out, 0);
138                out.push_str(" then ");
139                ast_to_if_string(then_branch, out, 0);
140                out.push_str(" else ");
141                ast_to_if_string(else_branch, out, 0);
142                out.push_str(" end");
143            }
144            AssertionExpr::Paren(inner) => {
145                out.push('(');
146                ast_to_if_string(inner, out, 0);
147                out.push(')');
148            }
149            AssertionExpr::Atom(atom) => out.push_str(&atom.to_string()),
150            AssertionExpr::Raw(raw) => out.push_str(raw),
151        }
152    }
153
154    if expr.is_empty() {
155        return None;
156    }
157
158    let parsed = parser::assertion_ast::parse_assertion(expr);
159    let reduced = parser::assertion_ast::remove_redundant_parens(&parsed);
160    let mut out = String::with_capacity(expr.len());
161    ast_to_if_string(&reduced, &mut out, 0);
162    Some(out)
163}
164
165#[derive(Debug, Clone, Copy)]
166struct RewriteRuleMetadata {
167    id: RuleId,
168    preconditions: &'static str,
169    negative_cases: &'static str,
170    proof_note: &'static str,
171}
172
173macro_rules! rule_id_table {
174    ($($name:ident => $value:literal),+ $(,)?) => {
175        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
176        pub enum RuleId {
177            $($name),+
178        }
179
180        impl RuleId {
181            pub const fn as_str(self) -> &'static str {
182                match self {
183                    $(Self::$name => $value),+
184                }
185            }
186        }
187
188        impl TryFrom<&str> for RuleId {
189            type Error = &'static str;
190
191            fn try_from(value: &str) -> Result<Self, Self::Error> {
192                match value {
193                    $($value => Ok(Self::$name)),+,
194                    _ => Err("unknown optimizer rule id"),
195                }
196            }
197        }
198
199        pub mod rule_ids {
200            use super::RuleId;
201            $(pub const $name: RuleId = RuleId::$name;)+
202        }
203    };
204}
205
206rule_id_table! {
207    B001 => "OPT_B001",
208    B002 => "OPT_B002",
209    B003 => "OPT_B003",
210    B004 => "OPT_B004",
211    B005 => "OPT_B005",
212    B006 => "OPT_B006",
213    B007 => "OPT_B007",
214    B008 => "OPT_B008",
215    B009 => "OPT_B009",
216    B010 => "OPT_B010",
217    B013 => "OPT_B013",
218    B014 => "OPT_B014",
219    B015 => "OPT_B015",
220    B016 => "OPT_B016",
221    B017 => "OPT_B017",
222    N001 => "OPT_N001",
223    N002 => "OPT_N002",
224    I001 => "OPT_I001",
225    I002 => "OPT_I002",
226    I003 => "OPT_I003",
227    I004 => "OPT_I004",
228    I005 => "OPT_I005",
229    P001 => "OPT_P001",
230    P002 => "OPT_P002",
231    T001 => "OPT_T001",
232    T002 => "OPT_T002",
233    R001 => "OPT_R001",
234    R002 => "OPT_R002",
235}
236
237impl std::fmt::Display for RuleId {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.write_str(self.as_str())
240    }
241}
242
243impl Serialize for RuleId {
244    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
245    where
246        S: Serializer,
247    {
248        serializer.serialize_str(self.as_str())
249    }
250}
251
252impl<'de> Deserialize<'de> for RuleId {
253    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
254    where
255        D: Deserializer<'de>,
256    {
257        let s = String::deserialize(deserializer)?;
258        RuleId::try_from(s.as_str()).map_err(serde::de::Error::custom)
259    }
260}
261
262const REWRITE_RULES: &[RewriteRuleMetadata] = &[
263    RewriteRuleMetadata {
264        id: rule_ids::B001,
265        preconditions: "lhs is boolean plugin expr and rhs is true",
266        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
267        proof_note: "Boolean identity: expr == true is equivalent to expr",
268    },
269    RewriteRuleMetadata {
270        id: rule_ids::B002,
271        preconditions: "lhs is boolean plugin expr and rhs is false",
272        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
273        proof_note: "Boolean negation: expr == false is equivalent to !expr",
274    },
275    RewriteRuleMetadata {
276        id: rule_ids::B003,
277        preconditions: "lhs is true and rhs is boolean plugin expr",
278        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
279        proof_note: "Boolean identity: true == expr is equivalent to expr",
280    },
281    RewriteRuleMetadata {
282        id: rule_ids::B004,
283        preconditions: "lhs is false and rhs is boolean plugin expr",
284        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
285        proof_note: "Boolean negation: false == expr is equivalent to !expr",
286    },
287    RewriteRuleMetadata {
288        id: rule_ids::B005,
289        preconditions: "expression has form !!<bool-plugin-expr>",
290        negative_cases: "inner expr is not proven boolean-safe",
291        proof_note: "Double negation elimination for boolean expressions",
292    },
293    RewriteRuleMetadata {
294        id: rule_ids::B006,
295        preconditions: "binary compare over two literals only",
296        negative_cases: "contains non-literals, dynamic plugin calls, or unknown values",
297        proof_note: "Constant folding preserves comparison result",
298    },
299    RewriteRuleMetadata {
300        id: rule_ids::B007,
301        preconditions: "expression has form x == x and x is idempotent",
302        negative_cases: "x may be non-idempotent or side-effectful",
303        proof_note: "Reflexive equality over idempotent expressions is always true",
304    },
305    RewriteRuleMetadata {
306        id: rule_ids::B008,
307        preconditions: "expression has form x != x and x is idempotent",
308        negative_cases: "x may be non-idempotent or side-effectful",
309        proof_note: "Reflexive inequality over idempotent expressions is always false",
310    },
311    RewriteRuleMetadata {
312        id: rule_ids::B013,
313        preconditions: "lhs is boolean plugin expr and rhs is true",
314        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
315        proof_note: "Boolean negation: expr != true is equivalent to !expr",
316    },
317    RewriteRuleMetadata {
318        id: rule_ids::B014,
319        preconditions: "lhs is boolean plugin expr and rhs is false",
320        negative_cases: "lhs is non-boolean, side-effectful, or unsafe-for-rewrite",
321        proof_note: "Boolean identity: expr != false is equivalent to expr",
322    },
323    RewriteRuleMetadata {
324        id: rule_ids::B015,
325        preconditions: "lhs is true and rhs is boolean plugin expr",
326        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
327        proof_note: "Boolean negation: true != expr is equivalent to !expr",
328    },
329    RewriteRuleMetadata {
330        id: rule_ids::B016,
331        preconditions: "lhs is false and rhs is boolean plugin expr",
332        negative_cases: "rhs is non-boolean, side-effectful, or unsafe-for-rewrite",
333        proof_note: "Boolean identity: false != expr is equivalent to expr",
334    },
335    RewriteRuleMetadata {
336        id: rule_ids::B017,
337        preconditions: "expression has form not not <bool-plugin-expr>",
338        negative_cases: "inner expr is not proven boolean-safe",
339        proof_note: "Word-style double negation elimination",
340    },
341    RewriteRuleMetadata {
342        id: rule_ids::N001,
343        preconditions: "operator alias startswith/endswith is present",
344        negative_cases: "already canonicalized form",
345        proof_note: "Canonical spelling rewrite preserves operator semantics",
346    },
347    RewriteRuleMetadata {
348        id: rule_ids::I001,
349        preconditions: "if-then-else with boolean literal condition",
350        negative_cases: "condition is not a literal true/false",
351        proof_note: "Dead branch elimination: if true then A else B end = A",
352    },
353    RewriteRuleMetadata {
354        id: rule_ids::I002,
355        preconditions: "if-then-else with identical then/else branches",
356        negative_cases: "branches are different expressions",
357        proof_note: "Branch merging: if C then X else X end = X",
358    },
359    RewriteRuleMetadata {
360        id: rule_ids::I003,
361        preconditions: "nested if with redundant condition check",
362        negative_cases: "conditions are not related",
363        proof_note: "Condition simplification for nested boolean expressions",
364    },
365    RewriteRuleMetadata {
366        id: rule_ids::I004,
367        preconditions: "if-then-else with boolean condition and literal branches",
368        negative_cases: "branches are not boolean literals",
369        proof_note: "Boolean simplification: if C then true else false end = C",
370    },
371    RewriteRuleMetadata {
372        id: rule_ids::I005,
373        preconditions: "if-then-else with negated condition pattern",
374        negative_cases: "branches don't match negation pattern",
375        proof_note: "Condition inversion: if C then false else true end = !C",
376    },
377    RewriteRuleMetadata {
378        id: rule_ids::B009,
379        preconditions: "boolean expression OR true/false",
380        negative_cases: "operand is not boolean literal",
381        proof_note: "Boolean identity: A or true = true, A or false = A",
382    },
383    RewriteRuleMetadata {
384        id: rule_ids::B010,
385        preconditions: "boolean expression AND true/false",
386        negative_cases: "operand is not boolean literal",
387        proof_note: "Boolean absorption: A and true = A, A and false = false",
388    },
389    RewriteRuleMetadata {
390        id: rule_ids::P001,
391        preconditions: "@len(expr) compared to zero",
392        negative_cases: "comparison is not with zero or not @len plugin",
393        proof_note: "Length check simplification: @len(x) == 0 = @empty(x)",
394    },
395    RewriteRuleMetadata {
396        id: rule_ids::P002,
397        preconditions: "expression wrapped in outer parentheses only",
398        negative_cases: "inner expression has internal parentheses (ambiguity risk)",
399        proof_note: "Redundant parentheses removal: (expr) = expr",
400    },
401    RewriteRuleMetadata {
402        id: rule_ids::N002,
403        preconditions: "negation of comparison operator",
404        negative_cases: "inner expression is not a comparison",
405        proof_note: "Comparison negation: not (A == B) = A != B",
406    },
407    RewriteRuleMetadata {
408        id: rule_ids::T001,
409        preconditions: "lhs is UInt plugin expr and rhs is 0",
410        negative_cases: "non-zero or non-UInt plugin",
411        proof_note: "UInt is always >= 0, so the comparison is always true",
412    },
413    RewriteRuleMetadata {
414        id: rule_ids::T002,
415        preconditions: "expression has `:TypeName` suffix and the inner expression already has that type",
416        negative_cases: "expression has `:TypeName` but the inner expression has a different or unknown type",
417        proof_note: "Type annotation is redundant when the type is already known",
418    },
419    RewriteRuleMetadata {
420        id: rule_ids::R001,
421        preconditions: "deprecated plugin call (uuid/email/ip/url/timestamp/empty)",
422        negative_cases: "already using canonical name",
423        proof_note: "Use canonical plugin name instead of deprecated one",
424    },
425    RewriteRuleMetadata {
426        id: rule_ids::R002,
427        preconditions: "negation pattern `!@is_empty(x)`",
428        negative_cases: "already using `@has_value`",
429        proof_note: "Use `@has_value` instead of `!@is_empty`",
430    },
431];
432
433fn rule_metadata(rule_id: RuleId) -> Option<&'static RewriteRuleMetadata> {
434    REWRITE_RULES.iter().find(|r| r.id == rule_id)
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct OptimizationHint {
439    pub rule_id: RuleId,
440    pub line: usize,
441    pub before: String,
442    pub after: String,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub preconditions: Option<String>,
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub negative_cases: Option<String>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub proof_note: Option<String>,
449}
450
451fn build_hint(rule_id: RuleId, line: usize, before: &str, after: String) -> OptimizationHint {
452    let meta = rule_metadata(rule_id);
453    OptimizationHint {
454        rule_id,
455        line,
456        before: before.to_string(),
457        after,
458        preconditions: meta.map(|m| m.preconditions.to_string()),
459        negative_cases: meta.map(|m| m.negative_cases.to_string()),
460        proof_note: meta.map(|m| m.proof_note.to_string()),
461    }
462}
463
464use apif_plugins::PLUGIN_SIGNATURES;
465
466static BOOLEAN_PLUGINS: LazyLock<HashSet<String>> = LazyLock::new(|| {
467    PLUGIN_SIGNATURES
468        .iter()
469        .filter(|(_, signature)| {
470            signature.return_type == TypeInfo::Bool
471                && signature.safe_for_rewrite
472                && signature.deterministic
473                && signature.idempotent
474        })
475        .map(|(name, _)| name.clone())
476        .collect()
477});
478
479fn plugin_signatures() -> &'static HashMap<String, PluginSignature> {
480    &PLUGIN_SIGNATURES
481}
482
483fn boolean_plugins() -> &'static HashSet<String> {
484    &BOOLEAN_PLUGINS
485}
486
487fn is_boolean_plugin_expr(expr: &str, bool_plugins: &HashSet<String>) -> bool {
488    let Some(plugin_name) = extract_plugin_call_name(expr) else {
489        return false;
490    };
491
492    bool_plugins.contains(plugin_name.as_str())
493}
494
495fn suggest_boolean_rewrite(
496    expr: &str,
497    bool_plugins: &HashSet<String>,
498    level: OptimizeLevel,
499) -> Option<(RuleId, String)> {
500    if !level.is_enabled(OptimizeLevel::Advisory) {
501        return None;
502    }
503    let (lhs, rhs) = expr.split_once("==")?;
504    let lhs = lhs.trim();
505    let rhs = rhs.trim();
506
507    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "true" {
508        return Some((rule_ids::B001, lhs.to_string()));
509    }
510    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "false" {
511        return Some((rule_ids::B002, format!("!{}", lhs)));
512    }
513    if lhs == "true" && is_boolean_plugin_expr(rhs, bool_plugins) {
514        return Some((rule_ids::B003, rhs.to_string()));
515    }
516    if lhs == "false" && is_boolean_plugin_expr(rhs, bool_plugins) {
517        return Some((rule_ids::B004, format!("!{}", rhs)));
518    }
519
520    None
521}
522
523fn suggest_not_not_rewrite(
524    expr: &str,
525    bool_plugins: &HashSet<String>,
526    level: OptimizeLevel,
527) -> Option<(RuleId, String)> {
528    if !level.is_enabled(OptimizeLevel::Safe) {
529        return None;
530    }
531    let trimmed = expr.trim();
532    if !trimmed.starts_with("not not ") {
533        return None;
534    }
535
536    let inner = trimmed[8..].trim();
537    if is_boolean_plugin_expr(inner, bool_plugins) {
538        return Some((rule_ids::B017, inner.to_string()));
539    }
540
541    None
542}
543
544fn suggest_inequality_rewrite(
545    expr: &str,
546    bool_plugins: &HashSet<String>,
547    level: OptimizeLevel,
548) -> Option<(RuleId, String)> {
549    if !level.is_enabled(OptimizeLevel::Advisory) {
550        return None;
551    }
552    let (lhs, rhs) = expr.split_once("!=")?;
553    let lhs = lhs.trim();
554    let rhs = rhs.trim();
555
556    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "true" {
557        return Some((rule_ids::B013, format!("!{}", lhs)));
558    }
559    if is_boolean_plugin_expr(lhs, bool_plugins) && rhs == "false" {
560        return Some((rule_ids::B014, lhs.to_string()));
561    }
562    if lhs == "true" && is_boolean_plugin_expr(rhs, bool_plugins) {
563        return Some((rule_ids::B015, format!("!{}", rhs)));
564    }
565    if lhs == "false" && is_boolean_plugin_expr(rhs, bool_plugins) {
566        return Some((rule_ids::B016, rhs.to_string()));
567    }
568
569    None
570}
571
572/// Redundant parentheses: (expr) -> expr (single expression, no ambiguity)
573fn suggest_redundant_parens(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
574    if !level.is_enabled(OptimizeLevel::Safe) {
575        return None;
576    }
577    let trimmed = expr.trim();
578    if !trimmed.starts_with('(') || !trimmed.ends_with(')') {
579        return None;
580    }
581
582    let inner = &trimmed[1..trimmed.len() - 1].trim();
583    if inner.is_empty() {
584        return None;
585    }
586
587    let balanced = inner.chars().fold(0i32, |acc, c| {
588        if c == '(' {
589            acc + 1
590        } else if c == ')' {
591            acc - 1
592        } else {
593            acc
594        }
595    });
596    if balanced != 0 {
597        return None;
598    }
599
600    Some((rule_ids::P002, inner.to_string()))
601}
602
603fn suggest_double_negation_rewrite(
604    expr: &str,
605    bool_plugins: &HashSet<String>,
606    level: OptimizeLevel,
607) -> Option<(RuleId, String)> {
608    if !level.is_enabled(OptimizeLevel::Safe) {
609        return None;
610    }
611    let trimmed = expr.trim();
612    if !trimmed.starts_with("!!") {
613        return None;
614    }
615
616    let inner = trimmed[2..].trim();
617    if is_boolean_plugin_expr(inner, bool_plugins) {
618        return Some((rule_ids::B005, inner.to_string()));
619    }
620
621    None
622}
623
624/// Replace `needle` with `replacement`, but only where `needle` occurs OUTSIDE
625/// of string literals. This prevents corrupting expected values that happen to
626/// contain the operator text (e.g. `.msg == "run startswith now"`).
627/// Returns `None` when no replacement outside of string literals was made.
628fn replace_outside_string_literals(expr: &str, needle: &str, replacement: &str) -> Option<String> {
629    let mut result = String::with_capacity(expr.len());
630    let mut in_quotes = false;
631    let mut quote_char = '\0';
632    let mut escaped = false;
633    let mut replaced = false;
634    let mut chars = expr.char_indices().peekable();
635
636    while let Some((i, c)) = chars.next() {
637        if in_quotes {
638            result.push(c);
639            if escaped {
640                escaped = false;
641            } else if c == '\\' {
642                escaped = true;
643            } else if c == quote_char {
644                in_quotes = false;
645            }
646            continue;
647        }
648
649        if c == '"' || c == '\'' {
650            in_quotes = true;
651            quote_char = c;
652            result.push(c);
653            continue;
654        }
655
656        if expr[i..].starts_with(needle) {
657            result.push_str(replacement);
658            replaced = true;
659            // Skip the remaining chars of the matched needle.
660            let end = i + needle.len();
661            while let Some(&(j, _)) = chars.peek() {
662                if j < end {
663                    chars.next();
664                } else {
665                    break;
666                }
667            }
668            continue;
669        }
670
671        result.push(c);
672    }
673
674    if replaced { Some(result) } else { None }
675}
676
677fn suggest_operator_canonicalization(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
678    if !level.is_enabled(OptimizeLevel::Safe) {
679        return None;
680    }
681    if let Some(rewritten) = replace_outside_string_literals(expr, " startswith ", " startsWith ") {
682        return Some((rule_ids::N001, rewritten));
683    }
684    if let Some(rewritten) = replace_outside_string_literals(expr, " endswith ", " endsWith ") {
685        return Some((rule_ids::N001, rewritten));
686    }
687    None
688}
689
690fn parse_literal(expr: &str) -> Option<serde_json::Value> {
691    let trimmed = expr.trim();
692    if trimmed.is_empty() {
693        return None;
694    }
695
696    if trimmed == "true" {
697        return Some(serde_json::Value::Bool(true));
698    }
699    if trimmed == "false" {
700        return Some(serde_json::Value::Bool(false));
701    }
702    if trimmed == "null" {
703        return Some(serde_json::Value::Null);
704    }
705
706    if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
707        return serde_json::from_str(trimmed).ok();
708    }
709
710    if let Ok(i) = trimmed.parse::<i64>() {
711        return Some(serde_json::Value::Number(serde_json::Number::from(i)));
712    }
713
714    if let Ok(f) = trimmed.parse::<f64>() {
715        return serde_json::Number::from_f64(f).map(serde_json::Value::Number);
716    }
717
718    None
719}
720
721fn suggest_constant_folding(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
722    if !level.is_enabled(OptimizeLevel::Aggressive) {
723        return None;
724    }
725    let operators = ["==", "!=", ">=", "<=", ">", "<"];
726    for op in operators {
727        let Some(idx) = expr.find(op) else {
728            continue;
729        };
730
731        let lhs_raw = expr[..idx].trim();
732        let rhs_raw = expr[idx + op.len()..].trim();
733        if lhs_raw.is_empty() || rhs_raw.is_empty() {
734            continue;
735        }
736
737        let Some(lhs) = parse_literal(lhs_raw) else {
738            continue;
739        };
740        let Some(rhs) = parse_literal(rhs_raw) else {
741            continue;
742        };
743
744        let folded = match op {
745            "==" => Some(lhs == rhs),
746            "!=" => Some(lhs != rhs),
747            ">" | "<" | ">=" | "<=" => compare_literal_numbers(&lhs, &rhs, op),
748            _ => None,
749        }?;
750
751        return Some((rule_ids::B006, folded.to_string()));
752    }
753
754    None
755}
756
757fn compare_literal_numbers(
758    lhs: &serde_json::Value,
759    rhs: &serde_json::Value,
760    op: &str,
761) -> Option<bool> {
762    let lhs_num = lhs.as_number()?;
763    let rhs_num = rhs.as_number()?;
764
765    let lhs_i = lhs_num
766        .as_i64()
767        .map(i128::from)
768        .or_else(|| lhs_num.as_u64().map(i128::from));
769    let rhs_i = rhs_num
770        .as_i64()
771        .map(i128::from)
772        .or_else(|| rhs_num.as_u64().map(i128::from));
773
774    if let (Some(l), Some(r)) = (lhs_i, rhs_i) {
775        return Some(match op {
776            ">" => l > r,
777            "<" => l < r,
778            ">=" => l >= r,
779            "<=" => l <= r,
780            _ => unreachable!(),
781        });
782    }
783
784    let (l, r) = (lhs_num.as_f64()?, rhs_num.as_f64()?);
785    Some(match op {
786        ">" => l > r,
787        "<" => l < r,
788        ">=" => l >= r,
789        "<=" => l <= r,
790        _ => unreachable!(),
791    })
792}
793
794fn is_idempotent_expr(expr: &str, signatures: &HashMap<String, PluginSignature>) -> bool {
795    let trimmed = expr.trim();
796    if trimmed.is_empty() {
797        return false;
798    }
799
800    if parse_literal(trimmed).is_some() {
801        return true;
802    }
803
804    if (trimmed.starts_with("{{") && trimmed.ends_with("}}"))
805        || trimmed.starts_with('$')
806        || trimmed.starts_with('.')
807    {
808        return true;
809    }
810
811    if trimmed.starts_with('(') && trimmed.ends_with(')') && trimmed.len() >= 2 {
812        return is_idempotent_expr(&trimmed[1..trimmed.len() - 1], signatures);
813    }
814
815    if let Some(plugin_name) = extract_plugin_call_name(trimmed) {
816        return signatures
817            .get(plugin_name.as_str())
818            .is_some_and(|sig| sig.idempotent);
819    }
820
821    false
822}
823
824fn suggest_reflexive_idempotent(
825    expr: &str,
826    signatures: &HashMap<String, PluginSignature>,
827    level: OptimizeLevel,
828) -> Option<(RuleId, String)> {
829    if !level.is_enabled(OptimizeLevel::Aggressive) {
830        return None;
831    }
832    let (_op, lhs, rhs, rule_id, result) = if let Some((l, r)) = expr.split_once("==") {
833        ("==", l, r, rule_ids::B007, "true")
834    } else if let Some((l, r)) = expr.split_once("!=") {
835        ("!=", l, r, rule_ids::B008, "false")
836    } else {
837        return None;
838    };
839
840    let lhs = lhs.trim();
841    let rhs = rhs.trim();
842
843    if lhs.is_empty() || rhs.is_empty() || lhs != rhs {
844        return None;
845    }
846
847    if parse_literal(lhs).is_some() && parse_literal(rhs).is_some() {
848        return None;
849    }
850
851    if !is_idempotent_expr(lhs, signatures) {
852        return None;
853    }
854
855    Some((rule_id, result.to_string()))
856}
857
858/// Parse if-then-else expression and extract parts
859fn parse_if_then_else(expr: &str) -> Option<(&str, &str, &str)> {
860    let expr = expr.trim();
861
862    if !expr.starts_with("if ") {
863        return None;
864    }
865
866    let bytes = expr.as_bytes();
867    let mut paren_depth = 0;
868    let mut if_depth = 0;
869    let mut then_pos = None;
870
871    let mut i = 0;
872    let mut in_string = false;
873    let mut string_char = None;
874    while i < bytes.len() {
875        // Handle string literals
876        if in_string {
877            if let Some(quote) = string_char
878                && bytes[i] == quote
879                && (i == 0 || bytes[i - 1] != b'\\')
880            {
881                in_string = false;
882            }
883            i += 1;
884            continue;
885        }
886        if bytes[i] == b'"' || bytes[i] == b'\'' {
887            in_string = true;
888            string_char = Some(bytes[i]);
889            i += 1;
890            continue;
891        }
892
893        match &bytes[i..i + 1] {
894            b"(" => paren_depth += 1,
895            b")" => paren_depth -= 1,
896            _ => {}
897        }
898
899        if paren_depth == 0 && i + 3 <= bytes.len() && &bytes[i..i + 3] == b"if " {
900            if_depth += 1;
901        }
902
903        if paren_depth == 0
904            && if_depth == 1
905            && i + 6 <= bytes.len()
906            && &bytes[i..i + 6] == b" then "
907        {
908            then_pos = Some(i);
909            break;
910        }
911
912        i += 1;
913    }
914
915    let then_pos = then_pos?;
916    let condition = expr[3..then_pos].trim();
917
918    let rest = &expr[then_pos + 6..];
919    let bytes = rest.as_bytes();
920    let mut else_pos = None;
921    let mut nested_if = 0;
922    paren_depth = 0;
923
924    let mut in_string = false;
925    let mut string_char = None;
926
927    i = 0;
928    while i < bytes.len() {
929        if in_string {
930            if let Some(quote) = string_char
931                && bytes[i] == quote
932                && (i == 0 || bytes[i - 1] != b'\\')
933            {
934                in_string = false;
935            }
936            i += 1;
937            continue;
938        }
939        if bytes[i] == b'"' || bytes[i] == b'\'' {
940            in_string = true;
941            string_char = Some(bytes[i]);
942            i += 1;
943            continue;
944        }
945
946        match &bytes[i..i + 1] {
947            b"(" => paren_depth += 1,
948            b")" => paren_depth -= 1,
949            _ => {}
950        }
951
952        if paren_depth == 0 && i + 3 <= bytes.len() && &bytes[i..i + 3] == b"if " {
953            nested_if += 1;
954        }
955
956        if paren_depth == 0 && i + 6 <= bytes.len() && &bytes[i..i + 6] == b" else " {
957            if nested_if == 0 {
958                else_pos = Some(i);
959                break;
960            }
961            nested_if -= 1;
962        }
963
964        i += 1;
965    }
966
967    let else_pos = else_pos?;
968    let then_expr = rest[..else_pos].trim();
969
970    let else_and_end = &rest[else_pos + 6..];
971    let else_expr = else_and_end.strip_suffix(" end")?.trim();
972
973    Some((condition, then_expr, else_expr))
974}
975
976/// Dead branch elimination: if true then A else B = A
977fn suggest_dead_branch_elimination(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
978    if !level.is_enabled(OptimizeLevel::Safe) {
979        return None;
980    }
981    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
982
983    if condition == "true" {
984        return Some((rule_ids::I001, then_expr.to_string()));
985    }
986
987    if condition == "false" {
988        return Some((rule_ids::I001, else_expr.to_string()));
989    }
990
991    None
992}
993
994/// Branch merging: if C then X else X = X
995fn suggest_branch_merging(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
996    if !level.is_enabled(OptimizeLevel::Advisory) {
997        return None;
998    }
999    let (_condition, then_expr, else_expr) = parse_if_then_else(expr)?;
1000
1001    if then_expr == else_expr {
1002        return Some((rule_ids::I002, then_expr.to_string()));
1003    }
1004
1005    None
1006}
1007
1008/// Nested if simplification: if A then (if A then X else Y) else Z = if A then X else Z
1009fn suggest_nested_if_simplification(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1010    if !level.is_enabled(OptimizeLevel::Advisory) {
1011        return None;
1012    }
1013    let (outer_cond, inner_expr, else_expr) = parse_if_then_else(expr)?;
1014
1015    // Strip parentheses from inner expression if present
1016    let inner_stripped = inner_expr.trim();
1017    let inner_stripped = if inner_stripped.starts_with('(') && inner_stripped.ends_with(')') {
1018        &inner_stripped[1..inner_stripped.len() - 1]
1019    } else {
1020        inner_stripped
1021    };
1022
1023    let (inner_cond, inner_then, _inner_else) = parse_if_then_else(inner_stripped)?;
1024
1025    if outer_cond == inner_cond {
1026        let result = format!(
1027            "if {} then {} else {} end",
1028            outer_cond, inner_then, else_expr
1029        );
1030        return Some((rule_ids::I003, result));
1031    }
1032
1033    None
1034}
1035
1036/// Boolean simplification: if C then true else false = C
1037fn suggest_boolean_simplification(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1038    if !level.is_enabled(OptimizeLevel::Advisory) {
1039        return None;
1040    }
1041    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
1042
1043    if then_expr == "true" && else_expr == "false" {
1044        return Some((rule_ids::I004, condition.to_string()));
1045    }
1046
1047    None
1048}
1049
1050fn needs_parens_for_prefix_not(expr: &str) -> bool {
1051    use apif_parser::assertion_ast::AssertionExpr;
1052
1053    let parsed = parser::assertion_ast::parse_assertion(expr.trim());
1054    let reduced = parser::assertion_ast::remove_redundant_parens(&parsed);
1055
1056    !matches!(reduced, AssertionExpr::Atom(_))
1057}
1058
1059fn negate_condition_expr(condition: &str) -> String {
1060    if let Some(negated) = negate_comparison_expr(condition) {
1061        return negated;
1062    }
1063
1064    let c = condition.trim();
1065    if c.starts_with('(') && c.ends_with(')') {
1066        return format!("!{}", c);
1067    }
1068
1069    if needs_parens_for_prefix_not(c) {
1070        format!("!({})", c)
1071    } else {
1072        format!("!{}", c)
1073    }
1074}
1075
1076/// Condition inversion: if C then false else true = !(C)
1077fn suggest_condition_inversion(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1078    if !level.is_enabled(OptimizeLevel::Advisory) {
1079        return None;
1080    }
1081    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
1082
1083    if then_expr == "false" && else_expr == "true" {
1084        Some((rule_ids::I005, negate_condition_expr(condition)))
1085    } else {
1086        None
1087    }
1088}
1089
1090/// Boolean identity/absorption: A or true = true, A and false = false
1091fn suggest_boolean_identity_laws(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1092    if !level.is_enabled(OptimizeLevel::Advisory) {
1093        return None;
1094    }
1095    let expr = expr.trim();
1096
1097    // Check for "or true" / "or false"
1098    if let Some(or_pos) = expr.find(" or ") {
1099        let left = expr[..or_pos].trim();
1100        let right = expr[or_pos + 4..].trim();
1101
1102        if right == "true" || left == "true" {
1103            return Some((rule_ids::B009, "true".to_string()));
1104        }
1105        if right == "false" {
1106            return Some((rule_ids::B009, left.to_string()));
1107        }
1108        if left == "false" {
1109            return Some((rule_ids::B009, right.to_string()));
1110        }
1111    }
1112
1113    // Check for "and true" / "and false"
1114    if let Some(and_pos) = expr.find(" and ") {
1115        let left = expr[..and_pos].trim();
1116        let right = expr[and_pos + 5..].trim();
1117
1118        if left == "true" {
1119            return Some((rule_ids::B010, right.to_string()));
1120        }
1121        if right == "true" {
1122            return Some((rule_ids::B010, left.to_string()));
1123        }
1124        if left == "false" || right == "false" {
1125            return Some((rule_ids::B010, "false".to_string()));
1126        }
1127    }
1128
1129    None
1130}
1131
1132/// Plugin-specific: @len(.x) == 0 → @empty(.x)
1133fn suggest_plugin_length_simplification(
1134    expr: &str,
1135    level: OptimizeLevel,
1136) -> Option<(RuleId, String)> {
1137    if !level.is_enabled(OptimizeLevel::Advisory) {
1138        return None;
1139    }
1140    fn extract_len_inner(s: &str) -> Option<&str> {
1141        // Match the close paren belonging to the FIRST `@len(` via depth
1142        // counting, and only accept it when that call spans the whole side.
1143        // Otherwise `@len(a) and @len(b)` would wrongly extract `a) and @len(b`.
1144        let rest = s.strip_prefix("@len(")?;
1145        let mut depth = 1usize;
1146        for (i, c) in rest.char_indices() {
1147            match c {
1148                '(' => depth += 1,
1149                ')' => {
1150                    depth -= 1;
1151                    if depth == 0 {
1152                        return if i == rest.len() - 1 {
1153                            Some(&rest[..i])
1154                        } else {
1155                            None
1156                        };
1157                    }
1158                }
1159                _ => {}
1160            }
1161        }
1162        None
1163    }
1164
1165    fn rewrite_len_zero_cmp(op: &str, inner: &str, len_on_left: bool) -> Option<String> {
1166        // `@len(x)` is unsigned, so it is always `>= 0`. The rewrite must be
1167        // operand-side aware: `@len(x) <= 0` collapses to `@empty(x)`, but
1168        // `0 <= @len(x)` is a tautology and must not become `@empty(x)`.
1169        match (op, len_on_left) {
1170            ("==", _) => Some(format!("@empty({})", inner)),
1171            ("<=", true) => Some(format!("@empty({})", inner)),
1172            ("<=", false) => Some("true".to_string()),
1173            ("!=", _) => Some(format!("@len({}) > 0", inner)),
1174            (">", true) => None,
1175            (">", false) => Some("false".to_string()),
1176            ("<", true) => Some("false".to_string()),
1177            ("<", false) => None,
1178            _ => None,
1179        }
1180    }
1181
1182    let expr = expr.trim();
1183
1184    // Patterns: @len(.x) == 0, @len(.x) != 0, @len(.x) > 0
1185    let operators = [
1186        (" == ", "=="),
1187        (" != ", "!="),
1188        (" > ", ">"),
1189        (" < ", "<"),
1190        (" <= ", "<="),
1191    ];
1192
1193    for (op_str, op_name) in operators {
1194        if let Some(op_pos) = expr.find(op_str) {
1195            let left = expr[..op_pos].trim();
1196            let right = expr[op_pos + op_str.len()..].trim();
1197
1198            if right == "0"
1199                && let Some(inner) = extract_len_inner(left)
1200            {
1201                return rewrite_len_zero_cmp(op_name, inner, true)
1202                    .map(|rewrite| (rule_ids::P001, rewrite));
1203            }
1204
1205            if left == "0"
1206                && let Some(inner) = extract_len_inner(right)
1207            {
1208                return rewrite_len_zero_cmp(op_name, inner, false)
1209                    .map(|rewrite| (rule_ids::P001, rewrite));
1210            }
1211        }
1212    }
1213
1214    None
1215}
1216
1217/// Type-aware numeric comparison optimization.
1218/// Uses TypeInfo to detect that certain plugins return unsigned integers,
1219/// making comparisons like `@len(.x) >= 0` always true.
1220fn suggest_type_aware_numeric_comparison(
1221    expr: &str,
1222    level: OptimizeLevel,
1223) -> Option<(RuleId, String)> {
1224    if !level.is_enabled(OptimizeLevel::Aggressive) {
1225        return None;
1226    }
1227    let signatures = plugin_signatures();
1228    let trimmed = expr.trim();
1229
1230    let (left, right) = if let Some(idx) = trimmed.find(">=") {
1231        (trimmed[..idx].trim(), trimmed[idx + 2..].trim())
1232    } else {
1233        let idx = trimmed.find("<=")?;
1234        (trimmed[..idx].trim(), trimmed[idx + 2..].trim())
1235    };
1236
1237    let plugin_call = if right == "0" {
1238        left
1239    } else if left == "0" {
1240        right
1241    } else {
1242        return None;
1243    };
1244
1245    if let Some(plugin_name) = extract_plugin_call_name(plugin_call)
1246        && let Some(sig) = signatures.get(plugin_name.as_str())
1247        && sig.return_type == TypeInfo::UInt
1248    {
1249        Some((rule_ids::T001, "true".to_string()))
1250    } else {
1251        None
1252    }
1253}
1254
1255/// Comparison negation: not (.x == 5) → .x != 5
1256fn suggest_comparison_negation(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1257    if !level.is_enabled(OptimizeLevel::Safe) {
1258        return None;
1259    }
1260    let expr = expr.trim();
1261
1262    let inner = if expr.starts_with("not (") && expr.ends_with(')') {
1263        expr[5..expr.len() - 1].trim()
1264    } else if expr.starts_with("!(") && expr.ends_with(')') {
1265        expr[2..expr.len() - 1].trim()
1266    } else {
1267        return None;
1268    };
1269
1270    negate_comparison_expr(inner).map(|rewritten| (rule_ids::N002, rewritten))
1271}
1272
1273fn negate_comparison_expr(inner: &str) -> Option<String> {
1274    let negations = [
1275        (" == ", " != "),
1276        (" != ", " == "),
1277        (" > ", " <= "),
1278        (" < ", " >= "),
1279        (" >= ", " < "),
1280        (" <= ", " > "),
1281    ];
1282
1283    for (op, neg_op) in negations {
1284        if let Some(op_pos) = inner.find(op) {
1285            let left = inner[..op_pos].trim();
1286            let right = inner[op_pos + op.len()..].trim();
1287
1288            if !left.is_empty() && !right.is_empty() {
1289                return Some(format!("{}{}{}", left, neg_op, right));
1290            }
1291        }
1292    }
1293
1294    None
1295}
1296
1297/// Detect redundant type annotations: `@len(.x):uint` → `@len(.x)` when `@len` already returns uint.
1298fn suggest_redundant_type_cast(
1299    expr: &str,
1300    signatures: &HashMap<String, PluginSignature>,
1301    level: OptimizeLevel,
1302) -> Option<(RuleId, String)> {
1303    if !level.is_enabled(OptimizeLevel::Safe) {
1304        return None;
1305    }
1306    let colon_pos = expr.rfind(':')?;
1307    if colon_pos == 0 {
1308        return None;
1309    }
1310
1311    let cast_type_name = &expr[colon_pos + 1..];
1312    let inner_expr = expr[..colon_pos].trim();
1313
1314    // Extract the type name (stop at non-alphanumeric chars)
1315    let cast_type_end = cast_type_name
1316        .find(|c: char| !c.is_alphanumeric() && c != '_')
1317        .unwrap_or(cast_type_name.len());
1318    let cast_type_name = &cast_type_name[..cast_type_end];
1319    if cast_type_name.is_empty() {
1320        return None;
1321    }
1322
1323    // Only consider casts into valid TypeInfo names
1324    let cast_type = TypeInfo::parse_type_name(cast_type_name)?;
1325
1326    // Infer type of inner expression
1327    let inner_tokens = parser::tokenizer::tokenize_assertion(inner_expr);
1328    let empty_vars = std::collections::HashMap::new();
1329    let inner_type = apif_semantics::infer_type_from_tokens(&inner_tokens, signatures, &empty_vars);
1330
1331    // If inner type is unknown, cast might be useful — don't flag
1332    if inner_type == TypeInfo::Any || inner_type == TypeInfo::Yaml || inner_type == TypeInfo::Json {
1333        return None;
1334    }
1335
1336    // If the cast type matches the inferred type, it's redundant
1337    let cast_base = cast_type.base_type();
1338    let inner_base = inner_type.base_type();
1339
1340    let types_match =
1341        cast_base == inner_base || (cast_base.is_numeric() && inner_base.is_numeric());
1342
1343    if !types_match {
1344        return None;
1345    }
1346
1347    // Build the rewritten expression by removing the `:type` suffix
1348    let after_colon = &expr[colon_pos + 1..];
1349    let rest = after_colon[cast_type_name.len()..].trim();
1350
1351    let rewritten = if rest.is_empty() {
1352        inner_expr.to_string()
1353    } else {
1354        format!("{} {}", inner_expr, rest)
1355    };
1356
1357    Some((rule_ids::T002, rewritten))
1358}
1359
1360/// Detect and rewrite deprecated plugin calls using PluginSignature metadata.
1361/// Also handles `!@is_empty(x)` → `@has_value(x)`.
1362fn suggest_deprecated_plugin_rename(
1363    expr: &str,
1364    signatures: &HashMap<String, PluginSignature>,
1365    level: OptimizeLevel,
1366) -> Option<(RuleId, String)> {
1367    if !level.is_enabled(OptimizeLevel::Safe) {
1368        return None;
1369    }
1370    let trimmed = expr.trim();
1371
1372    // Check for `!@is_empty(args)` → `@has_value(args)`
1373    if let Some(inner) = trimmed.strip_prefix("!@is_empty(")
1374        && inner.ends_with(')')
1375    {
1376        let args = &inner[..inner.len() - 1];
1377        return Some((rule_ids::R002, format!("@has_value({})", args)));
1378    }
1379
1380    // Check for `@is_empty(args) == false` → `@has_value(args)`
1381    if let Some(inner) = trimmed.strip_prefix("@is_empty(")
1382        && inner.ends_with(") == false")
1383    {
1384        let args = &inner[..inner.len() - 10];
1385        return Some((rule_ids::R002, format!("@has_value({})", args)));
1386    }
1387
1388    // Check for `false == @is_empty(args)` → `@has_value(args)`
1389    if let Some(inner) = trimmed.strip_prefix("false == @is_empty(")
1390        && inner.ends_with(')')
1391    {
1392        let args = &inner[..inner.len() - 1];
1393        return Some((rule_ids::R002, format!("@has_value({})", args)));
1394    }
1395
1396    // Generic deprecated plugin rename — read from PluginSignature.replacement
1397    for (name, sig) in signatures {
1398        let Some(replacement) = sig.replacement else {
1399            continue;
1400        };
1401        let at_name = format!("@{}", name);
1402        if let Some(rest) = trimmed.strip_prefix(&at_name)
1403            && rest.starts_with('(')
1404        {
1405            return Some((rule_ids::R001, format!("@{}{}", replacement, rest)));
1406        }
1407        // Handle !@name(...) pattern too — check for known boolean replacements
1408        let not_at_name = format!("!@{}", name);
1409        if let Some(rest) = trimmed.strip_prefix(&not_at_name)
1410            && rest.starts_with('(')
1411            && rest.ends_with(')')
1412        {
1413            let args = &rest[1..rest.len() - 1];
1414            // If the canonical replacement is `is_empty`, skip to `@has_value` directly
1415            if replacement == "is_empty" {
1416                return Some((rule_ids::R002, format!("@has_value({})", args)));
1417            }
1418            return Some((rule_ids::R001, format!("!@{}{}", replacement, rest)));
1419        }
1420    }
1421
1422    None
1423}
1424
1425fn rewrite_assertion_expression_with_context(
1426    expr: &str,
1427    signatures: &HashMap<String, PluginSignature>,
1428    bool_plugins: &HashSet<String>,
1429    normalization_mode: NormalizationMode,
1430    level: OptimizeLevel,
1431) -> Option<(RuleId, String)> {
1432    let normalized = normalize_expr_for_optimizer_with_mode(expr, normalization_mode);
1433    let expr = normalized.as_ref();
1434
1435    if let Some((rule_id, rewrite)) = suggest_boolean_rewrite(expr, bool_plugins, level) {
1436        return Some((rule_id, rewrite));
1437    }
1438
1439    if let Some((rule_id, rewrite)) = suggest_not_not_rewrite(expr, bool_plugins, level) {
1440        return Some((rule_id, rewrite));
1441    }
1442
1443    if let Some((rule_id, rewrite)) = suggest_inequality_rewrite(expr, bool_plugins, level) {
1444        return Some((rule_id, rewrite));
1445    }
1446
1447    if let Some((rule_id, rewrite)) = suggest_double_negation_rewrite(expr, bool_plugins, level) {
1448        return Some((rule_id, rewrite));
1449    }
1450
1451    if let Some((rule_id, rewrite)) = suggest_operator_canonicalization(expr, level) {
1452        return Some((rule_id, rewrite));
1453    }
1454
1455    if let Some((rule_id, rewrite)) = suggest_constant_folding(expr, level) {
1456        return Some((rule_id, rewrite));
1457    }
1458
1459    if let Some((rule_id, rewrite)) = suggest_reflexive_idempotent(expr, signatures, level) {
1460        return Some((rule_id, rewrite));
1461    }
1462
1463    if let Some((rule_id, rewrite)) = suggest_redundant_parens(expr, level) {
1464        return Some((rule_id, rewrite));
1465    }
1466
1467    // If-then-else optimizations
1468    if let Some((rule_id, rewrite)) = suggest_dead_branch_elimination(expr, level) {
1469        return Some((rule_id, rewrite));
1470    }
1471
1472    if let Some((rule_id, rewrite)) = suggest_branch_merging(expr, level) {
1473        return Some((rule_id, rewrite));
1474    }
1475
1476    if let Some((rule_id, rewrite)) = suggest_nested_if_simplification(expr, level) {
1477        return Some((rule_id, rewrite));
1478    }
1479
1480    if let Some((rule_id, rewrite)) = suggest_boolean_simplification(expr, level) {
1481        return Some((rule_id, rewrite));
1482    }
1483
1484    if let Some((rule_id, rewrite)) = suggest_condition_inversion(expr, level) {
1485        return Some((rule_id, rewrite));
1486    }
1487
1488    if let Some((rule_id, rewrite)) = suggest_boolean_identity_laws(expr, level) {
1489        return Some((rule_id, rewrite));
1490    }
1491
1492    if let Some((rule_id, rewrite)) = suggest_plugin_length_simplification(expr, level) {
1493        return Some((rule_id, rewrite));
1494    }
1495
1496    if let Some((rule_id, rewrite)) = suggest_type_aware_numeric_comparison(expr, level) {
1497        return Some((rule_id, rewrite));
1498    }
1499
1500    if let Some((rule_id, rewrite)) = suggest_redundant_type_cast(expr, signatures, level) {
1501        return Some((rule_id, rewrite));
1502    }
1503
1504    if let Some((rule_id, rewrite)) = suggest_deprecated_plugin_rename(expr, signatures, level) {
1505        return Some((rule_id, rewrite));
1506    }
1507
1508    suggest_comparison_negation(expr, level)
1509}
1510
1511fn rewrite_assertion_expression_fixed_point_with_mode(
1512    expr: &str,
1513    mode: NormalizationMode,
1514    level: OptimizeLevel,
1515) -> String {
1516    let signatures = plugin_signatures();
1517    let bool_plugins = boolean_plugins();
1518
1519    let mut current = Cow::Borrowed(expr.trim());
1520    for _ in 0..32 {
1521        let Some((_, rewritten)) = rewrite_assertion_expression_with_context(
1522            &current,
1523            signatures,
1524            bool_plugins,
1525            mode,
1526            level,
1527        ) else {
1528            break;
1529        };
1530
1531        let normalized = rewritten.trim();
1532        if normalized == current.as_ref() {
1533            break;
1534        }
1535        current = Cow::Owned(normalized.to_string());
1536    }
1537
1538    current.into_owned()
1539}
1540
1541pub fn rewrite_assertion_expression(expr: &str) -> Option<(&'static str, String)> {
1542    rewrite_assertion_expression_with_level(expr, OptimizeLevel::Advisory)
1543}
1544
1545pub fn rewrite_assertion_expression_with_level(
1546    expr: &str,
1547    level: OptimizeLevel,
1548) -> Option<(&'static str, String)> {
1549    let signatures = plugin_signatures();
1550    let bool_plugins = boolean_plugins();
1551    rewrite_assertion_expression_with_context(
1552        expr,
1553        signatures,
1554        bool_plugins,
1555        normalization_mode(),
1556        level,
1557    )
1558    .map(|(rule_id, rewrite)| (rule_id.as_str(), rewrite))
1559}
1560
1561pub fn rewrite_assertion_expression_fixed_point(expr: &str) -> String {
1562    rewrite_assertion_expression_fixed_point_with_level(expr, OptimizeLevel::Advisory)
1563}
1564
1565pub fn rewrite_assertion_expression_fixed_point_with_level(
1566    expr: &str,
1567    level: OptimizeLevel,
1568) -> String {
1569    rewrite_assertion_expression_fixed_point_with_mode(expr, normalization_mode(), level)
1570}
1571
1572pub fn rewrite_assertion_expression_fixed_point_if_changed(expr: &str) -> Option<String> {
1573    rewrite_assertion_expression_fixed_point_if_changed_with_level(expr, OptimizeLevel::Advisory)
1574}
1575
1576pub fn rewrite_assertion_expression_fixed_point_if_changed_with_level(
1577    expr: &str,
1578    level: OptimizeLevel,
1579) -> Option<String> {
1580    let trimmed = expr.trim();
1581    if trimmed.is_empty() || !likely_needs_assertion_rewrite(trimmed) {
1582        None
1583    } else {
1584        let rewritten = rewrite_assertion_expression_fixed_point_with_level(trimmed, level);
1585        if rewritten == trimmed {
1586            None
1587        } else {
1588            Some(rewritten)
1589        }
1590    }
1591}
1592
1593pub fn collect_assertion_optimizations(
1594    doc: &parser::GctfDocument,
1595    level: OptimizeLevel,
1596) -> Vec<OptimizationHint> {
1597    let signatures = plugin_signatures();
1598    let bool_plugins = boolean_plugins();
1599    let mode = normalization_mode();
1600    let mut hints = Vec::new();
1601
1602    for section in &doc.sections {
1603        if section.section_type != parser::ast::SectionType::Asserts {
1604            continue;
1605        }
1606
1607        for (idx, line) in section.raw_content.lines().enumerate() {
1608            let Some(trimmed) = strip_assertion_comments(line) else {
1609                continue;
1610            };
1611
1612            if !likely_needs_assertion_rewrite(&trimmed) {
1613                continue;
1614            }
1615
1616            if let Some((rule_id, rewrite)) = rewrite_assertion_expression_with_context(
1617                &trimmed,
1618                signatures,
1619                bool_plugins,
1620                mode,
1621                level,
1622            ) {
1623                debug_assert!(rule_metadata(rule_id).is_some());
1624                hints.push(build_hint(
1625                    rule_id,
1626                    section_content_line(section.start_line, idx),
1627                    &trimmed,
1628                    rewrite,
1629                ));
1630            }
1631        }
1632    }
1633
1634    hints
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639    use super::*;
1640
1641    fn ast_mode_active_for_tests() -> bool {
1642        matches!(normalization_mode(), NormalizationMode::AstCanonical)
1643    }
1644
1645    #[test]
1646    fn test_collect_assertion_optimizations_detects_boolean_rewrite() {
1647        let content = r#"--- ENDPOINT ---
1648test.Service/Method
1649
1650--- ASSERTS ---
1651@has_header("x-request-id") == true
1652"#;
1653
1654        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1655        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1656        assert_eq!(hints.len(), 1);
1657        assert_eq!(hints[0].rule_id, rule_ids::B001);
1658        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1659    }
1660
1661    #[test]
1662    fn test_collect_assertion_optimizations_detects_double_negation_rewrite() {
1663        let content = r#"--- ENDPOINT ---
1664test.Service/Method
1665
1666--- ASSERTS ---
1667!!@has_header("x-request-id")
1668"#;
1669
1670        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1671        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1672        assert_eq!(hints.len(), 1);
1673        if ast_mode_active_for_tests() {
1674            assert_eq!(hints[0].rule_id, rule_ids::B017);
1675        } else {
1676            assert_eq!(hints[0].rule_id, rule_ids::B005);
1677        }
1678        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1679    }
1680
1681    #[test]
1682    fn test_collect_assertion_optimizations_detects_operator_canonicalization() {
1683        let content = r#"--- ENDPOINT ---
1684test.Service/Method
1685
1686--- ASSERTS ---
1687.name startswith "abc"
1688"#;
1689
1690        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1691        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1692        if ast_mode_active_for_tests() {
1693            assert!(hints.is_empty());
1694        } else {
1695            assert_eq!(hints.len(), 1);
1696            assert_eq!(hints[0].rule_id, rule_ids::N001);
1697            assert_eq!(hints[0].after, ".name startsWith \"abc\"");
1698        }
1699    }
1700
1701    #[test]
1702    fn test_collect_assertion_optimizations_no_double_negation_for_non_boolean_plugin() {
1703        let content = r#"--- ENDPOINT ---
1704test.Service/Method
1705
1706--- ASSERTS ---
1707!!@len(.items)
1708"#;
1709
1710        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1711        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1712        assert!(hints.is_empty());
1713    }
1714
1715    #[test]
1716    fn test_collect_assertion_optimizations_constant_fold_numeric_compare() {
1717        let content = r#"--- ENDPOINT ---
1718test.Service/Method
1719
1720--- ASSERTS ---
17211 + 1 == 2
17223 > 2
1723"#;
1724
1725        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1726        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1727
1728        // Only '3 > 2' is a strict literal compare and safe to fold here.
1729        assert_eq!(hints.len(), 1);
1730        assert_eq!(hints[0].rule_id, rule_ids::B006);
1731        assert_eq!(hints[0].before, "3 > 2");
1732        assert_eq!(hints[0].after, "true");
1733    }
1734
1735    #[test]
1736    fn test_collect_assertion_optimizations_constant_fold_string_equality() {
1737        let content = r#"--- ENDPOINT ---
1738test.Service/Method
1739
1740--- ASSERTS ---
1741"a" == "a"
1742"#;
1743
1744        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1745        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1746        assert_eq!(hints.len(), 1);
1747        assert_eq!(hints[0].rule_id, rule_ids::B006);
1748        assert_eq!(hints[0].after, "true");
1749    }
1750
1751    #[test]
1752    fn test_rewrite_rule_metadata_is_complete() {
1753        let expected = [
1754            rule_ids::B001,
1755            rule_ids::B002,
1756            rule_ids::B003,
1757            rule_ids::B004,
1758            rule_ids::B005,
1759            rule_ids::B006,
1760            rule_ids::B007,
1761            rule_ids::B008,
1762            rule_ids::B009,
1763            rule_ids::B010,
1764            rule_ids::B013,
1765            rule_ids::B014,
1766            rule_ids::B015,
1767            rule_ids::B016,
1768            rule_ids::B017,
1769            rule_ids::N001,
1770            rule_ids::N002,
1771            rule_ids::I001,
1772            rule_ids::I002,
1773            rule_ids::I003,
1774            rule_ids::I004,
1775            rule_ids::I005,
1776            rule_ids::P001,
1777            rule_ids::P002,
1778            rule_ids::T001,
1779            rule_ids::T002,
1780            rule_ids::R001,
1781            rule_ids::R002,
1782        ];
1783
1784        for id in expected {
1785            let meta = rule_metadata(id).unwrap_or_else(|| panic!("missing metadata for {id}"));
1786            assert!(!meta.preconditions.is_empty());
1787            assert!(!meta.negative_cases.is_empty());
1788            assert!(!meta.proof_note.is_empty());
1789        }
1790    }
1791
1792    #[test]
1793    fn test_optimization_hint_contains_rule_metadata() {
1794        let content = r#"--- ENDPOINT ---
1795test.Service/Method
1796
1797--- ASSERTS ---
1798@has_header("x") == true
1799"#;
1800
1801        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1802        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1803        assert_eq!(hints.len(), 1);
1804        assert!(hints[0].preconditions.as_deref().is_some());
1805        assert!(hints[0].negative_cases.as_deref().is_some());
1806        assert!(hints[0].proof_note.as_deref().is_some());
1807    }
1808
1809    #[test]
1810    fn test_collect_assertion_optimizations_reflexive_idempotent_path() {
1811        let content = r#"--- ENDPOINT ---
1812test.Service/Method
1813
1814--- ASSERTS ---
1815.user.id == .user.id
1816"#;
1817
1818        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1819        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1820
1821        assert_eq!(hints.len(), 1);
1822        assert_eq!(hints[0].rule_id, rule_ids::B007);
1823        assert_eq!(hints[0].after, "true");
1824    }
1825
1826    #[test]
1827    fn test_collect_assertion_optimizations_no_reflexive_for_non_idempotent_plugin() {
1828        let content = r#"--- ENDPOINT ---
1829test.Service/Method
1830
1831--- ASSERTS ---
1832@env("HOME") == @env("HOME")
1833"#;
1834
1835        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1836        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1837
1838        assert!(hints.is_empty());
1839    }
1840
1841    #[test]
1842    fn test_collect_assertion_optimizations_reflexive_idempotent_inequality() {
1843        let content = r#"--- ENDPOINT ---
1844test.Service/Method
1845
1846--- ASSERTS ---
1847$user_id != $user_id
1848"#;
1849
1850        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1851        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1852
1853        assert_eq!(hints.len(), 1);
1854        assert_eq!(hints[0].rule_id, rule_ids::B008);
1855        assert_eq!(hints[0].after, "false");
1856    }
1857
1858    #[test]
1859    fn test_rewrite_assertion_expression_fixed_point() {
1860        let expr = "true == @has_header(\"x-request-id\")";
1861        let rewritten = rewrite_assertion_expression_fixed_point(expr);
1862        assert_eq!(rewritten, "@has_header(\"x-request-id\")");
1863    }
1864
1865    #[test]
1866    fn test_rewrite_assertion_expression_fixed_point_if_changed() {
1867        assert_eq!(
1868            rewrite_assertion_expression_fixed_point_if_changed(
1869                "true == @has_header(\"x-request-id\")"
1870            ),
1871            Some("@has_header(\"x-request-id\")".to_string())
1872        );
1873        assert_eq!(
1874            rewrite_assertion_expression_fixed_point_if_changed(".status == 200"),
1875            None
1876        );
1877    }
1878
1879    #[test]
1880    fn test_collect_assertion_optimizations_ignores_inline_comments() {
1881        let content = r#"--- ENDPOINT ---
1882test.Service/Method
1883
1884--- ASSERTS ---
1885true == @has_header("x-request-id") // comment should be ignored
1886"#;
1887
1888        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1889        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1890        assert_eq!(hints.len(), 1);
1891        assert_eq!(hints[0].rule_id, rule_ids::B003);
1892        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1893    }
1894
1895    #[test]
1896    fn test_likely_needs_assertion_rewrite_fast_path() {
1897        // Both old and new scope syntax trigger rewrite (contains @ and ==)
1898        assert!(likely_needs_assertion_rewrite("@scope_message_count()"));
1899        assert!(likely_needs_assertion_rewrite(
1900            "@scope.message_count() == 2"
1901        ));
1902        assert!(likely_needs_assertion_rewrite("@elapsed_ms() >= 10"));
1903        assert!(likely_needs_assertion_rewrite("true == @has_header(\"x\")"));
1904        assert!(likely_needs_assertion_rewrite(".name startswith \"abc\""));
1905        assert!(likely_needs_assertion_rewrite("if true then 1 else 2 end"));
1906    }
1907
1908    // === If-then-else optimization tests ===
1909
1910    #[test]
1911    fn test_dead_branch_elimination_true() {
1912        let (rule_id, rewritten) = suggest_dead_branch_elimination(
1913            "if true then \"yes\" else \"no\" end",
1914            OptimizeLevel::Advisory,
1915        )
1916        .unwrap();
1917        assert_eq!(rule_id, rule_ids::I001);
1918        assert_eq!(rewritten, "\"yes\"");
1919    }
1920
1921    #[test]
1922    fn test_dead_branch_elimination_false() {
1923        let (rule_id, rewritten) = suggest_dead_branch_elimination(
1924            "if false then \"yes\" else \"no\" end",
1925            OptimizeLevel::Advisory,
1926        )
1927        .unwrap();
1928        assert_eq!(rule_id, rule_ids::I001);
1929        assert_eq!(rewritten, "\"no\"");
1930    }
1931
1932    #[test]
1933    fn test_branch_merging() {
1934        let (rule_id, rewritten) = suggest_branch_merging(
1935            "if .x > 0 then \"same\" else \"same\" end",
1936            OptimizeLevel::Advisory,
1937        )
1938        .unwrap();
1939        assert_eq!(rule_id, rule_ids::I002);
1940        assert_eq!(rewritten, "\"same\"");
1941    }
1942
1943    #[test]
1944    fn test_nested_if_simplification() {
1945        // Pattern: if A then (if A then X else Y end) else Z end
1946        // Simplified: if A then X else Z end
1947        let input =
1948            "if .a > 0 then (if .a > 0 then \"inner\" else \"other\" end) else \"outer\" end";
1949        let result = suggest_nested_if_simplification(input, OptimizeLevel::Advisory);
1950        assert!(result.is_some());
1951        let (rule_id, rewritten) = result.unwrap();
1952        assert_eq!(rule_id, rule_ids::I003);
1953        assert_eq!(rewritten, "if .a > 0 then \"inner\" else \"outer\" end");
1954    }
1955
1956    #[test]
1957    fn test_parse_if_then_else_simple() {
1958        let (cond, then_expr, else_expr) =
1959            parse_if_then_else("if .x > 0 then \"yes\" else \"no\" end").unwrap();
1960        assert_eq!(cond, ".x > 0");
1961        assert_eq!(then_expr, "\"yes\"");
1962        assert_eq!(else_expr, "\"no\"");
1963    }
1964
1965    #[test]
1966    fn test_parse_if_then_else_nested() {
1967        let (cond, then_expr, else_expr) = parse_if_then_else(
1968            "if .a > 0 then (if .b > 0 then \"both\" else \"a only\" end) else \"none\" end",
1969        )
1970        .unwrap();
1971        assert_eq!(cond, ".a > 0");
1972        assert_eq!(then_expr, "(if .b > 0 then \"both\" else \"a only\" end)");
1973        assert_eq!(else_expr, "\"none\"");
1974    }
1975
1976    #[test]
1977    fn test_collect_optimizations_detects_dead_branch() {
1978        let content = r#"--- ENDPOINT ---
1979test.Service/Method
1980
1981--- ASSERTS ---
1982if true then "always" else "never" end
1983"#;
1984
1985        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1986        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1987        assert_eq!(hints.len(), 1);
1988        assert_eq!(hints[0].rule_id, rule_ids::I001);
1989        assert_eq!(hints[0].after, "\"always\"");
1990    }
1991
1992    #[test]
1993    fn test_collect_optimizations_detects_branch_merging() {
1994        let content = r#"--- ENDPOINT ---
1995test.Service/Method
1996
1997--- ASSERTS ---
1998if .x > 0 then "same" else "same" end
1999"#;
2000
2001        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2002        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2003        assert_eq!(hints.len(), 1);
2004        assert_eq!(hints[0].rule_id, rule_ids::I002);
2005        assert_eq!(hints[0].after, "\"same\"");
2006    }
2007
2008    #[test]
2009    fn test_boolean_simplification() {
2010        let (rule_id, rewritten) = suggest_boolean_simplification(
2011            "if .x > 0 then true else false end",
2012            OptimizeLevel::Advisory,
2013        )
2014        .unwrap();
2015        assert_eq!(rule_id, rule_ids::I004);
2016        assert_eq!(rewritten, ".x > 0");
2017    }
2018
2019    #[test]
2020    fn test_condition_inversion() {
2021        let (rule_id, rewritten) = suggest_condition_inversion(
2022            "if .x > 0 then false else true end",
2023            OptimizeLevel::Advisory,
2024        )
2025        .unwrap();
2026        assert_eq!(rule_id, rule_ids::I005);
2027        assert_eq!(rewritten, ".x <= 0");
2028    }
2029
2030    #[test]
2031    fn test_condition_inversion_contains_needs_parens() {
2032        let (rule_id, rewritten) = suggest_condition_inversion(
2033            "if .name contains \"foo\" then false else true end",
2034            OptimizeLevel::Advisory,
2035        )
2036        .unwrap();
2037        assert_eq!(rule_id, rule_ids::I005);
2038        assert_eq!(rewritten, "!(.name contains \"foo\")");
2039    }
2040
2041    #[test]
2042    fn test_condition_inversion_simple_plugin_call_no_parens() {
2043        let (rule_id, rewritten) = suggest_condition_inversion(
2044            "if @has_header(\"x\") then false else true end",
2045            OptimizeLevel::Advisory,
2046        )
2047        .unwrap();
2048        assert_eq!(rule_id, rule_ids::I005);
2049        assert_eq!(rewritten, "!@has_header(\"x\")");
2050    }
2051
2052    #[test]
2053    fn test_condition_inversion_not_keyword_gets_grouped() {
2054        let (rule_id, rewritten) = suggest_condition_inversion(
2055            "if not @has_header(\"x\") then false else true end",
2056            OptimizeLevel::Advisory,
2057        )
2058        .unwrap();
2059        assert_eq!(rule_id, rule_ids::I005);
2060        assert_eq!(rewritten, "!(not @has_header(\"x\"))");
2061    }
2062
2063    #[test]
2064    fn test_condition_inversion_bang_gets_grouped() {
2065        let (rule_id, rewritten) = suggest_condition_inversion(
2066            "if !@has_header(\"x\") then false else true end",
2067            OptimizeLevel::Advisory,
2068        )
2069        .unwrap();
2070        assert_eq!(rule_id, rule_ids::I005);
2071        assert_eq!(rewritten, "!(!@has_header(\"x\"))");
2072    }
2073
2074    #[test]
2075    fn test_condition_inversion_matches_gets_grouped() {
2076        let (rule_id, rewritten) = suggest_condition_inversion(
2077            "if .name matches /foo.*/ then false else true end",
2078            OptimizeLevel::Advisory,
2079        )
2080        .unwrap();
2081        assert_eq!(rule_id, rule_ids::I005);
2082        assert_eq!(rewritten, "!(.name matches /foo.*/)");
2083    }
2084
2085    #[test]
2086    fn test_collect_optimizations_boolean_simplification() {
2087        let content = r#"--- ENDPOINT ---
2088test.Service/Method
2089
2090--- ASSERTS ---
2091if @has_header("x") then true else false end
2092"#;
2093
2094        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2095        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2096        assert_eq!(hints.len(), 1);
2097        assert_eq!(hints[0].rule_id, rule_ids::I004);
2098        assert_eq!(hints[0].after, "@has_header(\"x\")");
2099    }
2100
2101    #[test]
2102    fn test_collect_optimizations_condition_inversion() {
2103        let content = r#"--- ENDPOINT ---
2104test.Service/Method
2105
2106--- ASSERTS ---
2107if .status == 200 then false else true end
2108"#;
2109
2110        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2111        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2112        assert_eq!(hints.len(), 1);
2113        assert_eq!(hints[0].rule_id, rule_ids::I005);
2114        assert_eq!(hints[0].after, ".status != 200");
2115    }
2116
2117    #[test]
2118    fn test_parse_if_then_else_string_with_else_keyword() {
2119        let (cond, then_expr, else_expr) =
2120            parse_if_then_else(r#"if true then " else " else "no" end"#).unwrap();
2121        assert_eq!(cond, "true");
2122        assert_eq!(then_expr, r#"" else ""#);
2123        assert_eq!(else_expr, r#""no""#);
2124    }
2125
2126    #[test]
2127    fn test_parse_if_then_else_then_in_string_condition() {
2128        let (cond, then_expr, else_expr) =
2129            parse_if_then_else(r#"if .x == "then" then "yes" else "no" end"#).unwrap();
2130        assert_eq!(cond, r#".x == "then""#);
2131        assert_eq!(then_expr, r#""yes""#);
2132        assert_eq!(else_expr, r#""no""#);
2133    }
2134
2135    // === New optimization rules tests ===
2136
2137    #[test]
2138    fn test_boolean_identity_or() {
2139        // A or true = true
2140        let (rule_id, rewritten) =
2141            suggest_boolean_identity_laws(".x or true", OptimizeLevel::Advisory).unwrap();
2142        assert_eq!(rule_id, rule_ids::B009);
2143        assert_eq!(rewritten, "true");
2144
2145        // A or false = A
2146        let (rule_id, rewritten) =
2147            suggest_boolean_identity_laws(".x or false", OptimizeLevel::Advisory).unwrap();
2148        assert_eq!(rule_id, rule_ids::B009);
2149        assert_eq!(rewritten, ".x");
2150
2151        // true or A = true
2152        let (rule_id, rewritten) =
2153            suggest_boolean_identity_laws("true or .x", OptimizeLevel::Advisory).unwrap();
2154        assert_eq!(rule_id, rule_ids::B009);
2155        assert_eq!(rewritten, "true");
2156    }
2157
2158    #[test]
2159    fn test_boolean_absorption_and() {
2160        // A and true = A
2161        let (rule_id, rewritten) =
2162            suggest_boolean_identity_laws(".x and true", OptimizeLevel::Advisory).unwrap();
2163        assert_eq!(rule_id, rule_ids::B010);
2164        assert_eq!(rewritten, ".x");
2165
2166        // A and false = false
2167        let (rule_id, rewritten) =
2168            suggest_boolean_identity_laws(".x and false", OptimizeLevel::Advisory).unwrap();
2169        assert_eq!(rule_id, rule_ids::B010);
2170        assert_eq!(rewritten, "false");
2171
2172        // false and A = false
2173        let (rule_id, rewritten) =
2174            suggest_boolean_identity_laws("false and .x", OptimizeLevel::Advisory).unwrap();
2175        assert_eq!(rule_id, rule_ids::B010);
2176        assert_eq!(rewritten, "false");
2177    }
2178
2179    #[test]
2180    fn test_plugin_length_simplification() {
2181        // @len(.x) == 0 → @empty(.x)
2182        let (rule_id, rewritten) =
2183            suggest_plugin_length_simplification("@len(.items) == 0", OptimizeLevel::Advisory)
2184                .unwrap();
2185        assert_eq!(rule_id, rule_ids::P001);
2186        assert_eq!(rewritten, "@empty(.items)");
2187
2188        // @len(.x) != 0 → @len(.x) > 0
2189        let (rule_id, rewritten) =
2190            suggest_plugin_length_simplification("@len(.items) != 0", OptimizeLevel::Advisory)
2191                .unwrap();
2192        assert_eq!(rule_id, rule_ids::P001);
2193        assert_eq!(rewritten, "@len(.items) > 0");
2194
2195        // @len(.x) > 0 → no simplification
2196        let result =
2197            suggest_plugin_length_simplification("@len(.items) > 0", OptimizeLevel::Advisory);
2198        assert!(result.is_none());
2199
2200        // 0 == @len(.x) → @empty(.x)
2201        let (rule_id, rewritten) =
2202            suggest_plugin_length_simplification("0 == @len(.items)", OptimizeLevel::Advisory)
2203                .unwrap();
2204        assert_eq!(rule_id, rule_ids::P001);
2205        assert_eq!(rewritten, "@empty(.items)");
2206    }
2207
2208    #[test]
2209    fn test_plugin_length_le_zero_is_operand_side_aware() {
2210        // @len(x) <= 0 is `@empty(x)` (len is unsigned, so <= 0 means == 0).
2211        let (rule_id, rewritten) =
2212            suggest_plugin_length_simplification("@len(.items) <= 0", OptimizeLevel::Advisory)
2213                .unwrap();
2214        assert_eq!(rule_id, rule_ids::P001);
2215        assert_eq!(rewritten, "@empty(.items)");
2216
2217        // 0 <= @len(x) is always true (len is unsigned) — must NOT become @empty(x).
2218        let (rule_id, rewritten) =
2219            suggest_plugin_length_simplification("0 <= @len(.items)", OptimizeLevel::Advisory)
2220                .unwrap();
2221        assert_eq!(rule_id, rule_ids::P001);
2222        assert_eq!(rewritten, "true");
2223        assert_ne!(rewritten, "@empty(.items)");
2224    }
2225
2226    #[test]
2227    fn test_comparison_negation() {
2228        // not (.x == 5) → .x != 5
2229        let (rule_id, rewritten) =
2230            suggest_comparison_negation("not (.x == 5)", OptimizeLevel::Advisory).unwrap();
2231        assert_eq!(rule_id, rule_ids::N002);
2232        assert_eq!(rewritten, ".x != 5");
2233
2234        // not (.x != 5) → .x == 5
2235        let (rule_id, rewritten) =
2236            suggest_comparison_negation("not (.x != 5)", OptimizeLevel::Advisory).unwrap();
2237        assert_eq!(rule_id, rule_ids::N002);
2238        assert_eq!(rewritten, ".x == 5");
2239
2240        // not (.x > 5) → .x <= 5
2241        let (rule_id, rewritten) =
2242            suggest_comparison_negation("not (.x > 5)", OptimizeLevel::Advisory).unwrap();
2243        assert_eq!(rule_id, rule_ids::N002);
2244        assert_eq!(rewritten, ".x <= 5");
2245
2246        // not (.x >= 5) → .x < 5
2247        let (rule_id, rewritten) =
2248            suggest_comparison_negation("not (.x >= 5)", OptimizeLevel::Advisory).unwrap();
2249        assert_eq!(rule_id, rule_ids::N002);
2250        assert_eq!(rewritten, ".x < 5");
2251
2252        // !(.x <= 5) -> .x > 5
2253        let (rule_id, rewritten) =
2254            suggest_comparison_negation("!(.x <= 5)", OptimizeLevel::Advisory).unwrap();
2255        assert_eq!(rule_id, rule_ids::N002);
2256        assert_eq!(rewritten, ".x > 5");
2257
2258        // malformed/non-comparison inner should not rewrite
2259        assert!(suggest_comparison_negation("!(.x)", OptimizeLevel::Advisory).is_none());
2260    }
2261
2262    #[test]
2263    fn test_collect_optimizations_boolean_identity() {
2264        let content = r#"--- ENDPOINT ---
2265test.Service/Method
2266
2267--- ASSERTS ---
2268@has_header("x") or true
2269"#;
2270
2271        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2272        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2273        assert_eq!(hints.len(), 1);
2274        assert_eq!(hints[0].rule_id, rule_ids::B009);
2275        assert_eq!(hints[0].after, "true");
2276    }
2277
2278    #[test]
2279    fn test_collect_optimizations_plugin_length() {
2280        let content = r#"--- ENDPOINT ---
2281test.Service/Method
2282
2283--- ASSERTS ---
2284@len(.items) == 0
2285"#;
2286
2287        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2288        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2289        assert_eq!(hints.len(), 1);
2290        assert_eq!(hints[0].rule_id, rule_ids::P001);
2291        assert_eq!(hints[0].after, "@empty(.items)");
2292    }
2293
2294    #[test]
2295    fn test_collect_optimizations_type_aware_uint_gte_zero() {
2296        let content = r#"--- ENDPOINT ---
2297test.Service/Method
2298
2299--- ASSERTS ---
2300@len(.items) >= 0
2301"#;
2302
2303        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2304        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
2305        assert_eq!(hints.len(), 1);
2306        assert_eq!(hints[0].rule_id, rule_ids::T001);
2307        assert_eq!(hints[0].after, "true");
2308    }
2309
2310    #[test]
2311    fn test_collect_optimizations_comparison_negation() {
2312        let content = r#"--- ENDPOINT ---
2313test.Service/Method
2314
2315--- ASSERTS ---
2316not (.status == 200)
2317"#;
2318
2319        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2320        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2321        assert_eq!(hints.len(), 1);
2322        assert_eq!(hints[0].rule_id, rule_ids::N002);
2323        assert_eq!(hints[0].after, ".status != 200");
2324    }
2325
2326    #[test]
2327    fn test_collect_optimizations_b002_expr_equals_false() {
2328        let content = r#"--- ENDPOINT ---
2329test.Service/Method
2330
2331--- ASSERTS ---
2332@has_header("x") == false
2333"#;
2334        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2335        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2336        assert_eq!(hints.len(), 1);
2337        assert_eq!(hints[0].rule_id, rule_ids::B002);
2338        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2339    }
2340
2341    #[test]
2342    fn test_collect_optimizations_b004_false_equals_expr() {
2343        let content = r#"--- ENDPOINT ---
2344test.Service/Method
2345
2346--- ASSERTS ---
2347false == @has_header("x")
2348"#;
2349        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2350        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2351        assert_eq!(hints.len(), 1);
2352        assert_eq!(hints[0].rule_id, rule_ids::B004);
2353        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2354    }
2355
2356    #[test]
2357    fn test_collect_optimizations_b013_inequality_true() {
2358        let content = r#"--- ENDPOINT ---
2359test.Service/Method
2360
2361--- ASSERTS ---
2362@has_header("x") != true
2363"#;
2364        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2365        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2366        assert_eq!(hints.len(), 1);
2367        assert_eq!(hints[0].rule_id, rule_ids::B013);
2368        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2369    }
2370
2371    #[test]
2372    fn test_collect_optimizations_b014_inequality_false() {
2373        let content = r#"--- ENDPOINT ---
2374test.Service/Method
2375
2376--- ASSERTS ---
2377@has_header("x") != false
2378"#;
2379        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2380        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2381        assert_eq!(hints.len(), 1);
2382        assert_eq!(hints[0].rule_id, rule_ids::B014);
2383        assert_eq!(hints[0].after, "@has_header(\"x\")");
2384    }
2385
2386    #[test]
2387    fn test_collect_optimizations_b015_true_inequality() {
2388        let content = r#"--- ENDPOINT ---
2389test.Service/Method
2390
2391--- ASSERTS ---
2392true != @has_header("x")
2393"#;
2394        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2395        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2396        assert_eq!(hints.len(), 1);
2397        assert_eq!(hints[0].rule_id, rule_ids::B015);
2398        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2399    }
2400
2401    #[test]
2402    fn test_collect_optimizations_b016_false_inequality() {
2403        let content = r#"--- ENDPOINT ---
2404test.Service/Method
2405
2406--- ASSERTS ---
2407false != @has_header("x")
2408"#;
2409        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2410        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2411        assert_eq!(hints.len(), 1);
2412        assert_eq!(hints[0].rule_id, rule_ids::B016);
2413        assert_eq!(hints[0].after, "@has_header(\"x\")");
2414    }
2415
2416    #[test]
2417    fn test_collect_optimizations_b017_double_not_word() {
2418        let content = r#"--- ENDPOINT ---
2419test.Service/Method
2420
2421--- ASSERTS ---
2422not not @has_header("x")
2423"#;
2424        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2425        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2426        assert_eq!(hints.len(), 1);
2427        assert_eq!(hints[0].rule_id, rule_ids::B017);
2428        assert_eq!(hints[0].after, "@has_header(\"x\")");
2429    }
2430
2431    #[test]
2432    fn test_collect_optimizations_p002_redundant_parens() {
2433        let result = rewrite_assertion_expression_fixed_point("(@has_header(\"x\"))");
2434        if ast_mode_active_for_tests() {
2435            assert_eq!(result, "(@has_header(\"x\"))");
2436        } else {
2437            assert_eq!(result, "@has_header(\"x\")");
2438        }
2439    }
2440
2441    #[test]
2442    fn test_boolean_plugins_contains_uuid() {
2443        let bp = boolean_plugins();
2444        assert!(bp.contains("uuid"));
2445        assert!(bp.contains("email"));
2446        assert!(bp.contains("empty"));
2447    }
2448
2449    #[test]
2450    fn test_plugin_signatures_returns_map() {
2451        let sigs = plugin_signatures();
2452        assert!(!sigs.is_empty());
2453        assert!(sigs.contains_key("uuid"));
2454    }
2455
2456    #[test]
2457    fn test_is_boolean_plugin_expr() {
2458        let bp = boolean_plugins();
2459        assert!(is_boolean_plugin_expr("@uuid(.x)", bp));
2460        assert!(is_boolean_plugin_expr("@empty(.items)", bp));
2461        assert!(!is_boolean_plugin_expr("@len(.x)", bp));
2462    }
2463
2464    #[test]
2465    fn test_suggest_constant_folding_string_equality() {
2466        let result = suggest_constant_folding("\"foo\" == \"foo\"", OptimizeLevel::Aggressive);
2467        assert!(result.is_some());
2468        let (rule_id, after) = result.unwrap();
2469        assert_eq!(rule_id, rule_ids::B006);
2470        assert_eq!(after, "true");
2471    }
2472
2473    #[test]
2474    fn test_suggest_constant_folding_mixed_types() {
2475        let result = suggest_constant_folding("\"foo\" == 123", OptimizeLevel::Aggressive);
2476        assert!(result.is_some());
2477        let (_rule_id, after) = result.unwrap();
2478        assert_eq!(after, "false");
2479    }
2480
2481    #[test]
2482    fn test_suggest_constant_folding_invalid_json() {
2483        let result = suggest_constant_folding("@len(.x) == 5", OptimizeLevel::Aggressive);
2484        assert!(result.is_none());
2485    }
2486
2487    #[test]
2488    fn test_normalization_mode_is_ast_canonical() {
2489        assert_eq!(normalization_mode(), NormalizationMode::AstCanonical);
2490    }
2491
2492    #[test]
2493    fn test_ast_mode_can_change_first_matching_rule() {
2494        let signatures = plugin_signatures();
2495        let bool_plugins = boolean_plugins();
2496        let expr = "((@has_header(\"x\"))) == true";
2497
2498        let conservative = rewrite_assertion_expression_with_context(
2499            expr,
2500            signatures,
2501            bool_plugins,
2502            NormalizationMode::Conservative,
2503            OptimizeLevel::Advisory,
2504        );
2505        let ast = rewrite_assertion_expression_with_context(
2506            expr,
2507            signatures,
2508            bool_plugins,
2509            NormalizationMode::AstCanonical,
2510            OptimizeLevel::Advisory,
2511        );
2512
2513        assert_eq!(conservative.map(|(id, _)| id), None);
2514        assert_eq!(ast.map(|(id, _)| id), Some(rule_ids::B001));
2515    }
2516
2517    #[test]
2518    fn test_ast_canonical_mode_preserves_execution_result() {
2519        use apif_assert::engine::{AssertionEngine, AssertionResult};
2520        use serde_json::json;
2521
2522        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2523        enum Outcome {
2524            Pass,
2525            Fail,
2526            Error,
2527        }
2528
2529        fn outcome_of(result: &AssertionResult) -> Outcome {
2530            match result {
2531                AssertionResult::Pass => Outcome::Pass,
2532                AssertionResult::Fail { .. } => Outcome::Fail,
2533                AssertionResult::Error(_) => Outcome::Error,
2534            }
2535        }
2536
2537        let engine =
2538            AssertionEngine::with_registry(std::sync::Arc::new(apif_plugins::PluginManager::new()));
2539        let cases = [
2540            "!!@has_header(\"x\")",
2541            "not not @has_header(\"x\")",
2542            "@has_header(\"x\") == true",
2543            "@has_header(\"x\") == false",
2544            "true != @has_header(\"x\")",
2545            ".name startswith \"abc\"",
2546            "not (.status == 200)",
2547            "if @has_header(\"x\") then true else false end",
2548            "if .status == 200 then false else true end",
2549            "if true then \"always\" else \"never\" end",
2550            "if .x > 0 then \"same\" else \"same\" end",
2551            "(@has_header(\"x\"))",
2552            "@len(.items) >= 0",
2553            "@len(.items) == 0",
2554            "@has_header(\"x\") == true and .status == 200",
2555            "true or @has_header(\"x\")",
2556        ];
2557
2558        let contexts = vec![
2559            (
2560                "status_200_with_header",
2561                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2] }),
2562                Some(std::collections::HashMap::from([(
2563                    "x".to_string(),
2564                    "1".to_string(),
2565                )])),
2566            ),
2567            (
2568                "status_200_without_header",
2569                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2] }),
2570                None,
2571            ),
2572            (
2573                "status_500_without_header",
2574                json!({ "status": 500, "name": "zzz", "x": 0, "items": [] }),
2575                None,
2576            ),
2577        ];
2578
2579        for (ctx_name, response, headers_owned) in contexts {
2580            let headers_ref = headers_owned.as_ref();
2581            for expr in cases {
2582                let conservative = rewrite_assertion_expression_fixed_point_with_mode(
2583                    expr,
2584                    NormalizationMode::Conservative,
2585                    OptimizeLevel::Advisory,
2586                );
2587                let ast = rewrite_assertion_expression_fixed_point_with_mode(
2588                    expr,
2589                    NormalizationMode::AstCanonical,
2590                    OptimizeLevel::Advisory,
2591                );
2592
2593                let before = engine.evaluate(expr, &response, headers_ref, None).unwrap();
2594                let after_conservative = engine
2595                    .evaluate(&conservative, &response, headers_ref, None)
2596                    .unwrap();
2597                let after_ast = engine.evaluate(&ast, &response, headers_ref, None).unwrap();
2598
2599                let before_outcome = outcome_of(&before);
2600                let conservative_outcome = outcome_of(&after_conservative);
2601                let ast_outcome = outcome_of(&after_ast);
2602
2603                assert_eq!(
2604                    before_outcome, conservative_outcome,
2605                    "conservative rewrite changed outcome in {ctx_name}: {expr} -> {conservative}",
2606                );
2607                assert_eq!(
2608                    before_outcome, ast_outcome,
2609                    "ast rewrite changed outcome in {ctx_name}: {expr} -> {ast}",
2610                );
2611
2612                let conservative_twice = rewrite_assertion_expression_fixed_point_with_mode(
2613                    &conservative,
2614                    NormalizationMode::Conservative,
2615                    OptimizeLevel::Advisory,
2616                );
2617                let ast_twice = rewrite_assertion_expression_fixed_point_with_mode(
2618                    &ast,
2619                    NormalizationMode::AstCanonical,
2620                    OptimizeLevel::Advisory,
2621                );
2622                assert_eq!(
2623                    conservative, conservative_twice,
2624                    "conservative rewrite not idempotent in {ctx_name}: {expr}",
2625                );
2626                assert_eq!(
2627                    ast, ast_twice,
2628                    "ast rewrite not idempotent in {ctx_name}: {expr}",
2629                );
2630
2631                let default_path = rewrite_assertion_expression_fixed_point(expr);
2632                assert_eq!(
2633                    default_path, ast,
2634                    "default rewrite diverged from ast mode in {ctx_name}: {expr}",
2635                );
2636            }
2637        }
2638    }
2639
2640    #[test]
2641    fn test_optimizer_hints_preserve_execution_result() {
2642        use apif_assert::engine::{AssertionEngine, AssertionResult};
2643        use serde_json::json;
2644
2645        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2646        enum Outcome {
2647            Pass,
2648            Fail,
2649            Error,
2650        }
2651
2652        fn outcome_of(result: &AssertionResult) -> Outcome {
2653            match result {
2654                AssertionResult::Pass => Outcome::Pass,
2655                AssertionResult::Fail { .. } => Outcome::Fail,
2656                AssertionResult::Error(_) => Outcome::Error,
2657            }
2658        }
2659
2660        let content = r#"--- ENDPOINT ---
2661test.Service/Method
2662
2663--- ASSERTS ---
2664@has_header("x") == true
2665@has_header("x") == false
2666false == @has_header("x")
2667@has_header("x") != true
2668!!@has_header("x")
2669not not @has_header("x")
2670.name startswith "abc"
26713 > 2
2672.user.id == .user.id
2673$user_id != $user_id
2674if true then "always" else "never" end
2675if .x > 0 then "same" else "same" end
2676if @has_header("x") then true else false end
2677if .status == 200 then false else true end
2678@len(.items) == 0
2679(@has_header("x"))
2680not (.status == 200)
2681@len(.items) >= 0
2682@has_header("x") or true
2683"#;
2684
2685        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2686        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2687        assert!(!hints.is_empty());
2688
2689        let engine =
2690            AssertionEngine::with_registry(std::sync::Arc::new(apif_plugins::PluginManager::new()));
2691        let contexts = vec![
2692            (
2693                "status_200_with_header",
2694                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2], "user": { "id": 1 } }),
2695                Some(std::collections::HashMap::from([(
2696                    "x".to_string(),
2697                    "1".to_string(),
2698                )])),
2699            ),
2700            (
2701                "status_200_without_header",
2702                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2], "user": { "id": 1 } }),
2703                None,
2704            ),
2705            (
2706                "status_500_without_header",
2707                json!({ "status": 500, "name": "zzz", "x": 0, "items": [], "user": { "id": 1 } }),
2708                None,
2709            ),
2710        ];
2711
2712        for hint in hints {
2713            for (ctx_name, response, headers_owned) in &contexts {
2714                let headers_ref = headers_owned.as_ref();
2715                let before = engine
2716                    .evaluate(&hint.before, response, headers_ref, None)
2717                    .unwrap();
2718                let after = engine
2719                    .evaluate(&hint.after, response, headers_ref, None)
2720                    .unwrap();
2721
2722                assert_eq!(
2723                    outcome_of(&before),
2724                    outcome_of(&after),
2725                    "rule {} changed outcome in {ctx_name}: '{}' -> '{}'",
2726                    hint.rule_id,
2727                    hint.before,
2728                    hint.after,
2729                );
2730            }
2731        }
2732    }
2733
2734    // ─── Redundant type cast tests ───────────────────────────────────
2735
2736    #[test]
2737    fn test_suggest_redundant_type_cast_len_uint() {
2738        let expr = "@len(.items):uint >= 0";
2739        let signatures = plugin_signatures();
2740        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2741        assert!(result.is_some(), "Expected redundant cast for @len(:uint)");
2742        if let Some((rule_id, rewritten)) = result {
2743            assert_eq!(rule_id, rule_ids::T002);
2744            assert_eq!(rewritten, "@len(.items) >= 0");
2745        }
2746    }
2747
2748    #[test]
2749    fn test_suggest_redundant_type_cast_header_string() {
2750        // @header returns String, so :string is redundant
2751        let expr = "@header(\"x\"):string != null";
2752        let signatures = plugin_signatures();
2753        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2754        assert!(
2755            result.is_some(),
2756            "Expected redundant cast for @header(:string)"
2757        );
2758        if let Some((rule_id, rewritten)) = result {
2759            assert_eq!(rule_id, rule_ids::T002);
2760            assert_eq!(rewritten, "@header(\"x\") != null");
2761        }
2762    }
2763
2764    #[test]
2765    fn test_suggest_redundant_type_cast_len_to_number() {
2766        // @len returns UInt, :number is numeric-compatible → redundant
2767        let expr = "@len(.items):number >= 0";
2768        let signatures = plugin_signatures();
2769        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2770        assert!(
2771            result.is_some(),
2772            "Expected redundant cast for @len(:number)"
2773        );
2774        if let Some((_, rewritten)) = result {
2775            assert_eq!(rewritten, "@len(.items) >= 0");
2776        }
2777    }
2778
2779    #[test]
2780    fn test_suggest_non_redundant_type_cast_number() {
2781        // .price:number is NOT redundant because .price is Any
2782        let expr = ".price:number >= 0";
2783        let signatures = plugin_signatures();
2784        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2785        assert!(
2786            result.is_none(),
2787            "Should not flag .price:number as redundant"
2788        );
2789    }
2790
2791    #[test]
2792    fn test_suggest_non_redundant_type_cast_string() {
2793        // .name:string is NOT redundant because .name is Any
2794        let expr = ".name:string contains \"hello\"";
2795        let signatures = plugin_signatures();
2796        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2797        assert!(
2798            result.is_none(),
2799            "Should not flag .name:string as redundant"
2800        );
2801    }
2802
2803    #[test]
2804    fn test_collect_redundant_type_cast_optimization() {
2805        let content = r#"--- ENDPOINT ---
2806test.Service/Method
2807
2808--- ASSERTS ---
2809@len(.items):uint >= 0
2810"#;
2811        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2812        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2813        assert!(!hints.is_empty(), "Expected at least one optimization hint");
2814        assert_eq!(hints[0].rule_id, rule_ids::T002);
2815        assert_eq!(hints[0].after, "@len(.items) >= 0");
2816    }
2817
2818    #[test]
2819    fn test_operator_canonicalization_skips_string_literals() {
2820        // Regression: `startswith`/`endswith` inside a string literal must NOT
2821        // be rewritten (previously a blind str::replace corrupted the value).
2822        assert_eq!(
2823            suggest_operator_canonicalization(
2824                r#".msg == "run startswith now""#,
2825                OptimizeLevel::Safe
2826            ),
2827            None
2828        );
2829        assert_eq!(
2830            suggest_operator_canonicalization(r#".msg == "x endswith y""#, OptimizeLevel::Safe),
2831            None
2832        );
2833        // A genuine operator token is still canonicalized.
2834        assert_eq!(
2835            suggest_operator_canonicalization(r#".msg startswith "abc""#, OptimizeLevel::Safe),
2836            Some((rule_ids::N001, r#".msg startsWith "abc""#.to_string()))
2837        );
2838    }
2839
2840    #[test]
2841    fn test_len_zero_simplification_requires_whole_lhs() {
2842        // Regression: the @len(...) call must span the entire compared side.
2843        // `@len(a) and @len(b) == 0` must not become `@empty(a) and @len(b)`.
2844        assert_eq!(
2845            suggest_plugin_length_simplification(
2846                "@len(a) and @len(b) == 0",
2847                OptimizeLevel::Advisory
2848            ),
2849            None
2850        );
2851        // Simple whole-LHS case still works.
2852        assert_eq!(
2853            suggest_plugin_length_simplification("@len(.x) == 0", OptimizeLevel::Advisory),
2854            Some((rule_ids::P001, "@empty(.x)".to_string()))
2855        );
2856        // Nested parens inside the argument are matched correctly.
2857        assert_eq!(
2858            suggest_plugin_length_simplification("@len(f(.x)) == 0", OptimizeLevel::Advisory),
2859            Some((rule_ids::P001, "@empty(f(.x))".to_string()))
2860        );
2861    }
2862
2863    #[test]
2864    fn test_deprecated_rename_no_panic_on_unclosed_paren() {
2865        // Regression: `!@<deprecated>(` with no closing paren must not panic
2866        // on the slice `rest[1..rest.len()-1]`.
2867        assert_eq!(
2868            suggest_deprecated_plugin_rename("!@uuid(", plugin_signatures(), OptimizeLevel::Safe),
2869            None
2870        );
2871    }
2872}