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
624fn suggest_operator_canonicalization(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
625    if !level.is_enabled(OptimizeLevel::Safe) {
626        return None;
627    }
628    if expr.contains(" startswith ") {
629        let rewritten = expr.replace(" startswith ", " startsWith ");
630        return Some((rule_ids::N001, rewritten));
631    }
632    if expr.contains(" endswith ") {
633        let rewritten = expr.replace(" endswith ", " endsWith ");
634        return Some((rule_ids::N001, rewritten));
635    }
636    None
637}
638
639fn parse_literal(expr: &str) -> Option<serde_json::Value> {
640    let trimmed = expr.trim();
641    if trimmed.is_empty() {
642        return None;
643    }
644
645    if trimmed == "true" {
646        return Some(serde_json::Value::Bool(true));
647    }
648    if trimmed == "false" {
649        return Some(serde_json::Value::Bool(false));
650    }
651    if trimmed == "null" {
652        return Some(serde_json::Value::Null);
653    }
654
655    if trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2 {
656        return serde_json::from_str(trimmed).ok();
657    }
658
659    if let Ok(i) = trimmed.parse::<i64>() {
660        return Some(serde_json::Value::Number(serde_json::Number::from(i)));
661    }
662
663    if let Ok(f) = trimmed.parse::<f64>() {
664        return serde_json::Number::from_f64(f).map(serde_json::Value::Number);
665    }
666
667    None
668}
669
670fn suggest_constant_folding(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
671    if !level.is_enabled(OptimizeLevel::Aggressive) {
672        return None;
673    }
674    let operators = ["==", "!=", ">=", "<=", ">", "<"];
675    for op in operators {
676        let Some(idx) = expr.find(op) else {
677            continue;
678        };
679
680        let lhs_raw = expr[..idx].trim();
681        let rhs_raw = expr[idx + op.len()..].trim();
682        if lhs_raw.is_empty() || rhs_raw.is_empty() {
683            continue;
684        }
685
686        let Some(lhs) = parse_literal(lhs_raw) else {
687            continue;
688        };
689        let Some(rhs) = parse_literal(rhs_raw) else {
690            continue;
691        };
692
693        let folded = match op {
694            "==" => Some(lhs == rhs),
695            "!=" => Some(lhs != rhs),
696            ">" | "<" | ">=" | "<=" => compare_literal_numbers(&lhs, &rhs, op),
697            _ => None,
698        }?;
699
700        return Some((rule_ids::B006, folded.to_string()));
701    }
702
703    None
704}
705
706fn compare_literal_numbers(
707    lhs: &serde_json::Value,
708    rhs: &serde_json::Value,
709    op: &str,
710) -> Option<bool> {
711    let lhs_num = lhs.as_number()?;
712    let rhs_num = rhs.as_number()?;
713
714    let lhs_i = lhs_num
715        .as_i64()
716        .map(i128::from)
717        .or_else(|| lhs_num.as_u64().map(i128::from));
718    let rhs_i = rhs_num
719        .as_i64()
720        .map(i128::from)
721        .or_else(|| rhs_num.as_u64().map(i128::from));
722
723    if let (Some(l), Some(r)) = (lhs_i, rhs_i) {
724        return Some(match op {
725            ">" => l > r,
726            "<" => l < r,
727            ">=" => l >= r,
728            "<=" => l <= r,
729            _ => unreachable!(),
730        });
731    }
732
733    let (l, r) = (lhs_num.as_f64()?, rhs_num.as_f64()?);
734    Some(match op {
735        ">" => l > r,
736        "<" => l < r,
737        ">=" => l >= r,
738        "<=" => l <= r,
739        _ => unreachable!(),
740    })
741}
742
743fn is_idempotent_expr(expr: &str, signatures: &HashMap<String, PluginSignature>) -> bool {
744    let trimmed = expr.trim();
745    if trimmed.is_empty() {
746        return false;
747    }
748
749    if parse_literal(trimmed).is_some() {
750        return true;
751    }
752
753    if (trimmed.starts_with("{{") && trimmed.ends_with("}}"))
754        || trimmed.starts_with('$')
755        || trimmed.starts_with('.')
756    {
757        return true;
758    }
759
760    if trimmed.starts_with('(') && trimmed.ends_with(')') && trimmed.len() >= 2 {
761        return is_idempotent_expr(&trimmed[1..trimmed.len() - 1], signatures);
762    }
763
764    if let Some(plugin_name) = extract_plugin_call_name(trimmed) {
765        return signatures
766            .get(plugin_name.as_str())
767            .is_some_and(|sig| sig.idempotent);
768    }
769
770    false
771}
772
773fn suggest_reflexive_idempotent(
774    expr: &str,
775    signatures: &HashMap<String, PluginSignature>,
776    level: OptimizeLevel,
777) -> Option<(RuleId, String)> {
778    if !level.is_enabled(OptimizeLevel::Aggressive) {
779        return None;
780    }
781    let (_op, lhs, rhs, rule_id, result) = if let Some((l, r)) = expr.split_once("==") {
782        ("==", l, r, rule_ids::B007, "true")
783    } else if let Some((l, r)) = expr.split_once("!=") {
784        ("!=", l, r, rule_ids::B008, "false")
785    } else {
786        return None;
787    };
788
789    let lhs = lhs.trim();
790    let rhs = rhs.trim();
791
792    if lhs.is_empty() || rhs.is_empty() || lhs != rhs {
793        return None;
794    }
795
796    if parse_literal(lhs).is_some() && parse_literal(rhs).is_some() {
797        return None;
798    }
799
800    if !is_idempotent_expr(lhs, signatures) {
801        return None;
802    }
803
804    Some((rule_id, result.to_string()))
805}
806
807/// Parse if-then-else expression and extract parts
808fn parse_if_then_else(expr: &str) -> Option<(&str, &str, &str)> {
809    let expr = expr.trim();
810
811    if !expr.starts_with("if ") {
812        return None;
813    }
814
815    let bytes = expr.as_bytes();
816    let mut paren_depth = 0;
817    let mut if_depth = 0;
818    let mut then_pos = None;
819
820    let mut i = 0;
821    let mut in_string = false;
822    let mut string_char = None;
823    while i < bytes.len() {
824        // Handle string literals
825        if in_string {
826            if let Some(quote) = string_char
827                && bytes[i] == quote
828                && (i == 0 || bytes[i - 1] != b'\\')
829            {
830                in_string = false;
831            }
832            i += 1;
833            continue;
834        }
835        if bytes[i] == b'"' || bytes[i] == b'\'' {
836            in_string = true;
837            string_char = Some(bytes[i]);
838            i += 1;
839            continue;
840        }
841
842        match &bytes[i..i + 1] {
843            b"(" => paren_depth += 1,
844            b")" => paren_depth -= 1,
845            _ => {}
846        }
847
848        if paren_depth == 0 && i + 3 <= bytes.len() && &bytes[i..i + 3] == b"if " {
849            if_depth += 1;
850        }
851
852        if paren_depth == 0
853            && if_depth == 1
854            && i + 6 <= bytes.len()
855            && &bytes[i..i + 6] == b" then "
856        {
857            then_pos = Some(i);
858            break;
859        }
860
861        i += 1;
862    }
863
864    let then_pos = then_pos?;
865    let condition = expr[3..then_pos].trim();
866
867    let rest = &expr[then_pos + 6..];
868    let bytes = rest.as_bytes();
869    let mut else_pos = None;
870    let mut nested_if = 0;
871    paren_depth = 0;
872
873    let mut in_string = false;
874    let mut string_char = None;
875
876    i = 0;
877    while i < bytes.len() {
878        if in_string {
879            if let Some(quote) = string_char
880                && bytes[i] == quote
881                && (i == 0 || bytes[i - 1] != b'\\')
882            {
883                in_string = false;
884            }
885            i += 1;
886            continue;
887        }
888        if bytes[i] == b'"' || bytes[i] == b'\'' {
889            in_string = true;
890            string_char = Some(bytes[i]);
891            i += 1;
892            continue;
893        }
894
895        match &bytes[i..i + 1] {
896            b"(" => paren_depth += 1,
897            b")" => paren_depth -= 1,
898            _ => {}
899        }
900
901        if paren_depth == 0 && i + 3 <= bytes.len() && &bytes[i..i + 3] == b"if " {
902            nested_if += 1;
903        }
904
905        if paren_depth == 0 && i + 6 <= bytes.len() && &bytes[i..i + 6] == b" else " {
906            if nested_if == 0 {
907                else_pos = Some(i);
908                break;
909            }
910            nested_if -= 1;
911        }
912
913        i += 1;
914    }
915
916    let else_pos = else_pos?;
917    let then_expr = rest[..else_pos].trim();
918
919    let else_and_end = &rest[else_pos + 6..];
920    let else_expr = else_and_end.strip_suffix(" end")?.trim();
921
922    Some((condition, then_expr, else_expr))
923}
924
925/// Dead branch elimination: if true then A else B = A
926fn suggest_dead_branch_elimination(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
927    if !level.is_enabled(OptimizeLevel::Safe) {
928        return None;
929    }
930    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
931
932    if condition == "true" {
933        return Some((rule_ids::I001, then_expr.to_string()));
934    }
935
936    if condition == "false" {
937        return Some((rule_ids::I001, else_expr.to_string()));
938    }
939
940    None
941}
942
943/// Branch merging: if C then X else X = X
944fn suggest_branch_merging(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
945    if !level.is_enabled(OptimizeLevel::Advisory) {
946        return None;
947    }
948    let (_condition, then_expr, else_expr) = parse_if_then_else(expr)?;
949
950    if then_expr == else_expr {
951        return Some((rule_ids::I002, then_expr.to_string()));
952    }
953
954    None
955}
956
957/// Nested if simplification: if A then (if A then X else Y) else Z = if A then X else Z
958fn suggest_nested_if_simplification(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
959    if !level.is_enabled(OptimizeLevel::Advisory) {
960        return None;
961    }
962    let (outer_cond, inner_expr, else_expr) = parse_if_then_else(expr)?;
963
964    // Strip parentheses from inner expression if present
965    let inner_stripped = inner_expr.trim();
966    let inner_stripped = if inner_stripped.starts_with('(') && inner_stripped.ends_with(')') {
967        &inner_stripped[1..inner_stripped.len() - 1]
968    } else {
969        inner_stripped
970    };
971
972    let (inner_cond, inner_then, _inner_else) = parse_if_then_else(inner_stripped)?;
973
974    if outer_cond == inner_cond {
975        let result = format!(
976            "if {} then {} else {} end",
977            outer_cond, inner_then, else_expr
978        );
979        return Some((rule_ids::I003, result));
980    }
981
982    None
983}
984
985/// Boolean simplification: if C then true else false = C
986fn suggest_boolean_simplification(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
987    if !level.is_enabled(OptimizeLevel::Advisory) {
988        return None;
989    }
990    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
991
992    if then_expr == "true" && else_expr == "false" {
993        return Some((rule_ids::I004, condition.to_string()));
994    }
995
996    None
997}
998
999fn needs_parens_for_prefix_not(expr: &str) -> bool {
1000    use apif_parser::assertion_ast::AssertionExpr;
1001
1002    let parsed = parser::assertion_ast::parse_assertion(expr.trim());
1003    let reduced = parser::assertion_ast::remove_redundant_parens(&parsed);
1004
1005    !matches!(reduced, AssertionExpr::Atom(_))
1006}
1007
1008fn negate_condition_expr(condition: &str) -> String {
1009    if let Some(negated) = negate_comparison_expr(condition) {
1010        return negated;
1011    }
1012
1013    let c = condition.trim();
1014    if c.starts_with('(') && c.ends_with(')') {
1015        return format!("!{}", c);
1016    }
1017
1018    if needs_parens_for_prefix_not(c) {
1019        format!("!({})", c)
1020    } else {
1021        format!("!{}", c)
1022    }
1023}
1024
1025/// Condition inversion: if C then false else true = !(C)
1026fn suggest_condition_inversion(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1027    if !level.is_enabled(OptimizeLevel::Advisory) {
1028        return None;
1029    }
1030    let (condition, then_expr, else_expr) = parse_if_then_else(expr)?;
1031
1032    if then_expr == "false" && else_expr == "true" {
1033        Some((rule_ids::I005, negate_condition_expr(condition)))
1034    } else {
1035        None
1036    }
1037}
1038
1039/// Boolean identity/absorption: A or true = true, A and false = false
1040fn suggest_boolean_identity_laws(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1041    if !level.is_enabled(OptimizeLevel::Advisory) {
1042        return None;
1043    }
1044    let expr = expr.trim();
1045
1046    // Check for "or true" / "or false"
1047    if let Some(or_pos) = expr.find(" or ") {
1048        let left = expr[..or_pos].trim();
1049        let right = expr[or_pos + 4..].trim();
1050
1051        if right == "true" || left == "true" {
1052            return Some((rule_ids::B009, "true".to_string()));
1053        }
1054        if right == "false" {
1055            return Some((rule_ids::B009, left.to_string()));
1056        }
1057        if left == "false" {
1058            return Some((rule_ids::B009, right.to_string()));
1059        }
1060    }
1061
1062    // Check for "and true" / "and false"
1063    if let Some(and_pos) = expr.find(" and ") {
1064        let left = expr[..and_pos].trim();
1065        let right = expr[and_pos + 5..].trim();
1066
1067        if left == "true" {
1068            return Some((rule_ids::B010, right.to_string()));
1069        }
1070        if right == "true" {
1071            return Some((rule_ids::B010, left.to_string()));
1072        }
1073        if left == "false" || right == "false" {
1074            return Some((rule_ids::B010, "false".to_string()));
1075        }
1076    }
1077
1078    None
1079}
1080
1081/// Plugin-specific: @len(.x) == 0 → @empty(.x)
1082fn suggest_plugin_length_simplification(
1083    expr: &str,
1084    level: OptimizeLevel,
1085) -> Option<(RuleId, String)> {
1086    if !level.is_enabled(OptimizeLevel::Advisory) {
1087        return None;
1088    }
1089    fn extract_len_inner(s: &str) -> Option<&str> {
1090        if s.starts_with("@len(") && s.ends_with(')') {
1091            Some(&s[5..s.len() - 1])
1092        } else {
1093            None
1094        }
1095    }
1096
1097    fn rewrite_len_zero_cmp(op: &str, inner: &str, len_on_left: bool) -> Option<String> {
1098        match (op, len_on_left) {
1099            ("==", _) | ("<=", _) => Some(format!("@empty({})", inner)),
1100            ("!=", _) => Some(format!("@len({}) > 0", inner)),
1101            (">", true) => None,
1102            (">", false) => Some("false".to_string()),
1103            ("<", true) => Some("false".to_string()),
1104            ("<", false) => None,
1105            _ => None,
1106        }
1107    }
1108
1109    let expr = expr.trim();
1110
1111    // Patterns: @len(.x) == 0, @len(.x) != 0, @len(.x) > 0
1112    let operators = [
1113        (" == ", "=="),
1114        (" != ", "!="),
1115        (" > ", ">"),
1116        (" < ", "<"),
1117        (" <= ", "<="),
1118    ];
1119
1120    for (op_str, op_name) in operators {
1121        if let Some(op_pos) = expr.find(op_str) {
1122            let left = expr[..op_pos].trim();
1123            let right = expr[op_pos + op_str.len()..].trim();
1124
1125            if right == "0"
1126                && let Some(inner) = extract_len_inner(left)
1127            {
1128                return rewrite_len_zero_cmp(op_name, inner, true)
1129                    .map(|rewrite| (rule_ids::P001, rewrite));
1130            }
1131
1132            if left == "0"
1133                && let Some(inner) = extract_len_inner(right)
1134            {
1135                return rewrite_len_zero_cmp(op_name, inner, false)
1136                    .map(|rewrite| (rule_ids::P001, rewrite));
1137            }
1138        }
1139    }
1140
1141    None
1142}
1143
1144/// Type-aware numeric comparison optimization.
1145/// Uses TypeInfo to detect that certain plugins return unsigned integers,
1146/// making comparisons like `@len(.x) >= 0` always true.
1147fn suggest_type_aware_numeric_comparison(
1148    expr: &str,
1149    level: OptimizeLevel,
1150) -> Option<(RuleId, String)> {
1151    if !level.is_enabled(OptimizeLevel::Aggressive) {
1152        return None;
1153    }
1154    let signatures = plugin_signatures();
1155    let trimmed = expr.trim();
1156
1157    let (left, right) = if let Some(idx) = trimmed.find(">=") {
1158        (trimmed[..idx].trim(), trimmed[idx + 2..].trim())
1159    } else {
1160        let idx = trimmed.find("<=")?;
1161        (trimmed[..idx].trim(), trimmed[idx + 2..].trim())
1162    };
1163
1164    let plugin_call = if right == "0" {
1165        left
1166    } else if left == "0" {
1167        right
1168    } else {
1169        return None;
1170    };
1171
1172    if let Some(plugin_name) = extract_plugin_call_name(plugin_call)
1173        && let Some(sig) = signatures.get(plugin_name.as_str())
1174        && sig.return_type == TypeInfo::UInt
1175    {
1176        Some((rule_ids::T001, "true".to_string()))
1177    } else {
1178        None
1179    }
1180}
1181
1182/// Comparison negation: not (.x == 5) → .x != 5
1183fn suggest_comparison_negation(expr: &str, level: OptimizeLevel) -> Option<(RuleId, String)> {
1184    if !level.is_enabled(OptimizeLevel::Safe) {
1185        return None;
1186    }
1187    let expr = expr.trim();
1188
1189    let inner = if expr.starts_with("not (") && expr.ends_with(')') {
1190        expr[5..expr.len() - 1].trim()
1191    } else if expr.starts_with("!(") && expr.ends_with(')') {
1192        expr[2..expr.len() - 1].trim()
1193    } else {
1194        return None;
1195    };
1196
1197    negate_comparison_expr(inner).map(|rewritten| (rule_ids::N002, rewritten))
1198}
1199
1200fn negate_comparison_expr(inner: &str) -> Option<String> {
1201    let negations = [
1202        (" == ", " != "),
1203        (" != ", " == "),
1204        (" > ", " <= "),
1205        (" < ", " >= "),
1206        (" >= ", " < "),
1207        (" <= ", " > "),
1208    ];
1209
1210    for (op, neg_op) in negations {
1211        if let Some(op_pos) = inner.find(op) {
1212            let left = inner[..op_pos].trim();
1213            let right = inner[op_pos + op.len()..].trim();
1214
1215            if !left.is_empty() && !right.is_empty() {
1216                return Some(format!("{}{}{}", left, neg_op, right));
1217            }
1218        }
1219    }
1220
1221    None
1222}
1223
1224/// Detect redundant type annotations: `@len(.x):uint` → `@len(.x)` when `@len` already returns uint.
1225fn suggest_redundant_type_cast(
1226    expr: &str,
1227    signatures: &HashMap<String, PluginSignature>,
1228    level: OptimizeLevel,
1229) -> Option<(RuleId, String)> {
1230    if !level.is_enabled(OptimizeLevel::Safe) {
1231        return None;
1232    }
1233    let colon_pos = expr.rfind(':')?;
1234    if colon_pos == 0 {
1235        return None;
1236    }
1237
1238    let cast_type_name = &expr[colon_pos + 1..];
1239    let inner_expr = expr[..colon_pos].trim();
1240
1241    // Extract the type name (stop at non-alphanumeric chars)
1242    let cast_type_end = cast_type_name
1243        .find(|c: char| !c.is_alphanumeric() && c != '_')
1244        .unwrap_or(cast_type_name.len());
1245    let cast_type_name = &cast_type_name[..cast_type_end];
1246    if cast_type_name.is_empty() {
1247        return None;
1248    }
1249
1250    // Only consider casts into valid TypeInfo names
1251    let cast_type = TypeInfo::parse_type_name(cast_type_name)?;
1252
1253    // Infer type of inner expression
1254    let inner_tokens = parser::tokenizer::tokenize_assertion(inner_expr);
1255    let empty_vars = std::collections::HashMap::new();
1256    let inner_type = apif_semantics::infer_type_from_tokens(&inner_tokens, signatures, &empty_vars);
1257
1258    // If inner type is unknown, cast might be useful — don't flag
1259    if inner_type == TypeInfo::Any || inner_type == TypeInfo::Yaml || inner_type == TypeInfo::Json {
1260        return None;
1261    }
1262
1263    // If the cast type matches the inferred type, it's redundant
1264    let cast_base = cast_type.base_type();
1265    let inner_base = inner_type.base_type();
1266
1267    let types_match =
1268        cast_base == inner_base || (cast_base.is_numeric() && inner_base.is_numeric());
1269
1270    if !types_match {
1271        return None;
1272    }
1273
1274    // Build the rewritten expression by removing the `:type` suffix
1275    let after_colon = &expr[colon_pos + 1..];
1276    let rest = after_colon[cast_type_name.len()..].trim();
1277
1278    let rewritten = if rest.is_empty() {
1279        inner_expr.to_string()
1280    } else {
1281        format!("{} {}", inner_expr, rest)
1282    };
1283
1284    Some((rule_ids::T002, rewritten))
1285}
1286
1287/// Detect and rewrite deprecated plugin calls using PluginSignature metadata.
1288/// Also handles `!@is_empty(x)` → `@has_value(x)`.
1289fn suggest_deprecated_plugin_rename(
1290    expr: &str,
1291    signatures: &HashMap<String, PluginSignature>,
1292    level: OptimizeLevel,
1293) -> Option<(RuleId, String)> {
1294    if !level.is_enabled(OptimizeLevel::Safe) {
1295        return None;
1296    }
1297    let trimmed = expr.trim();
1298
1299    // Check for `!@is_empty(args)` → `@has_value(args)`
1300    if let Some(inner) = trimmed.strip_prefix("!@is_empty(")
1301        && inner.ends_with(')')
1302    {
1303        let args = &inner[..inner.len() - 1];
1304        return Some((rule_ids::R002, format!("@has_value({})", args)));
1305    }
1306
1307    // Check for `@is_empty(args) == false` → `@has_value(args)`
1308    if let Some(inner) = trimmed.strip_prefix("@is_empty(")
1309        && inner.ends_with(") == false")
1310    {
1311        let args = &inner[..inner.len() - 10];
1312        return Some((rule_ids::R002, format!("@has_value({})", args)));
1313    }
1314
1315    // Check for `false == @is_empty(args)` → `@has_value(args)`
1316    if let Some(inner) = trimmed.strip_prefix("false == @is_empty(")
1317        && inner.ends_with(')')
1318    {
1319        let args = &inner[..inner.len() - 1];
1320        return Some((rule_ids::R002, format!("@has_value({})", args)));
1321    }
1322
1323    // Generic deprecated plugin rename — read from PluginSignature.replacement
1324    for (name, sig) in signatures {
1325        let Some(replacement) = sig.replacement else {
1326            continue;
1327        };
1328        let at_name = format!("@{}", name);
1329        if let Some(rest) = trimmed.strip_prefix(&at_name)
1330            && rest.starts_with('(')
1331        {
1332            return Some((rule_ids::R001, format!("@{}{}", replacement, rest)));
1333        }
1334        // Handle !@name(...) pattern too — check for known boolean replacements
1335        let not_at_name = format!("!@{}", name);
1336        if let Some(rest) = trimmed.strip_prefix(&not_at_name)
1337            && rest.starts_with('(')
1338        {
1339            let args = &rest[1..rest.len() - 1];
1340            // If the canonical replacement is `is_empty`, skip to `@has_value` directly
1341            if replacement == "is_empty" {
1342                return Some((rule_ids::R002, format!("@has_value({})", args)));
1343            }
1344            return Some((rule_ids::R001, format!("!@{}{}", replacement, rest)));
1345        }
1346    }
1347
1348    None
1349}
1350
1351fn rewrite_assertion_expression_with_context(
1352    expr: &str,
1353    signatures: &HashMap<String, PluginSignature>,
1354    bool_plugins: &HashSet<String>,
1355    normalization_mode: NormalizationMode,
1356    level: OptimizeLevel,
1357) -> Option<(RuleId, String)> {
1358    let normalized = normalize_expr_for_optimizer_with_mode(expr, normalization_mode);
1359    let expr = normalized.as_ref();
1360
1361    if let Some((rule_id, rewrite)) = suggest_boolean_rewrite(expr, bool_plugins, level) {
1362        return Some((rule_id, rewrite));
1363    }
1364
1365    if let Some((rule_id, rewrite)) = suggest_not_not_rewrite(expr, bool_plugins, level) {
1366        return Some((rule_id, rewrite));
1367    }
1368
1369    if let Some((rule_id, rewrite)) = suggest_inequality_rewrite(expr, bool_plugins, level) {
1370        return Some((rule_id, rewrite));
1371    }
1372
1373    if let Some((rule_id, rewrite)) = suggest_double_negation_rewrite(expr, bool_plugins, level) {
1374        return Some((rule_id, rewrite));
1375    }
1376
1377    if let Some((rule_id, rewrite)) = suggest_operator_canonicalization(expr, level) {
1378        return Some((rule_id, rewrite));
1379    }
1380
1381    if let Some((rule_id, rewrite)) = suggest_constant_folding(expr, level) {
1382        return Some((rule_id, rewrite));
1383    }
1384
1385    if let Some((rule_id, rewrite)) = suggest_reflexive_idempotent(expr, signatures, level) {
1386        return Some((rule_id, rewrite));
1387    }
1388
1389    // Redundant parentheses removal
1390    if let Some((rule_id, rewrite)) = suggest_redundant_parens(expr, level) {
1391        return Some((rule_id, rewrite));
1392    }
1393
1394    // If-then-else optimizations
1395    if let Some((rule_id, rewrite)) = suggest_dead_branch_elimination(expr, level) {
1396        return Some((rule_id, rewrite));
1397    }
1398
1399    if let Some((rule_id, rewrite)) = suggest_branch_merging(expr, level) {
1400        return Some((rule_id, rewrite));
1401    }
1402
1403    if let Some((rule_id, rewrite)) = suggest_nested_if_simplification(expr, level) {
1404        return Some((rule_id, rewrite));
1405    }
1406
1407    if let Some((rule_id, rewrite)) = suggest_boolean_simplification(expr, level) {
1408        return Some((rule_id, rewrite));
1409    }
1410
1411    if let Some((rule_id, rewrite)) = suggest_condition_inversion(expr, level) {
1412        return Some((rule_id, rewrite));
1413    }
1414
1415    // Boolean identity/absorption laws
1416    if let Some((rule_id, rewrite)) = suggest_boolean_identity_laws(expr, level) {
1417        return Some((rule_id, rewrite));
1418    }
1419
1420    // Plugin-specific optimizations
1421    if let Some((rule_id, rewrite)) = suggest_plugin_length_simplification(expr, level) {
1422        return Some((rule_id, rewrite));
1423    }
1424
1425    // Type-aware optimizations based on TypeInfo
1426    if let Some((rule_id, rewrite)) = suggest_type_aware_numeric_comparison(expr, level) {
1427        return Some((rule_id, rewrite));
1428    }
1429
1430    // Redundant type annotation removal
1431    if let Some((rule_id, rewrite)) = suggest_redundant_type_cast(expr, signatures, level) {
1432        return Some((rule_id, rewrite));
1433    }
1434
1435    // Deprecated plugin rename
1436    if let Some((rule_id, rewrite)) = suggest_deprecated_plugin_rename(expr, signatures, level) {
1437        return Some((rule_id, rewrite));
1438    }
1439
1440    // Comparison negation normalization
1441    suggest_comparison_negation(expr, level)
1442}
1443
1444fn rewrite_assertion_expression_fixed_point_with_mode(
1445    expr: &str,
1446    mode: NormalizationMode,
1447    level: OptimizeLevel,
1448) -> String {
1449    let signatures = plugin_signatures();
1450    let bool_plugins = boolean_plugins();
1451
1452    let mut current = Cow::Borrowed(expr.trim());
1453    for _ in 0..32 {
1454        let Some((_, rewritten)) = rewrite_assertion_expression_with_context(
1455            &current,
1456            signatures,
1457            bool_plugins,
1458            mode,
1459            level,
1460        ) else {
1461            break;
1462        };
1463
1464        let normalized = rewritten.trim();
1465        if normalized == current.as_ref() {
1466            break;
1467        }
1468        current = Cow::Owned(normalized.to_string());
1469    }
1470
1471    current.into_owned()
1472}
1473
1474pub fn rewrite_assertion_expression(expr: &str) -> Option<(&'static str, String)> {
1475    rewrite_assertion_expression_with_level(expr, OptimizeLevel::Advisory)
1476}
1477
1478pub fn rewrite_assertion_expression_with_level(
1479    expr: &str,
1480    level: OptimizeLevel,
1481) -> Option<(&'static str, String)> {
1482    let signatures = plugin_signatures();
1483    let bool_plugins = boolean_plugins();
1484    rewrite_assertion_expression_with_context(
1485        expr,
1486        signatures,
1487        bool_plugins,
1488        normalization_mode(),
1489        level,
1490    )
1491    .map(|(rule_id, rewrite)| (rule_id.as_str(), rewrite))
1492}
1493
1494pub fn rewrite_assertion_expression_fixed_point(expr: &str) -> String {
1495    rewrite_assertion_expression_fixed_point_with_level(expr, OptimizeLevel::Advisory)
1496}
1497
1498pub fn rewrite_assertion_expression_fixed_point_with_level(
1499    expr: &str,
1500    level: OptimizeLevel,
1501) -> String {
1502    rewrite_assertion_expression_fixed_point_with_mode(expr, normalization_mode(), level)
1503}
1504
1505pub fn rewrite_assertion_expression_fixed_point_if_changed(expr: &str) -> Option<String> {
1506    rewrite_assertion_expression_fixed_point_if_changed_with_level(expr, OptimizeLevel::Advisory)
1507}
1508
1509pub fn rewrite_assertion_expression_fixed_point_if_changed_with_level(
1510    expr: &str,
1511    level: OptimizeLevel,
1512) -> Option<String> {
1513    let trimmed = expr.trim();
1514    if trimmed.is_empty() || !likely_needs_assertion_rewrite(trimmed) {
1515        None
1516    } else {
1517        let rewritten = rewrite_assertion_expression_fixed_point_with_level(trimmed, level);
1518        if rewritten == trimmed {
1519            None
1520        } else {
1521            Some(rewritten)
1522        }
1523    }
1524}
1525
1526pub fn collect_assertion_optimizations(
1527    doc: &parser::GctfDocument,
1528    level: OptimizeLevel,
1529) -> Vec<OptimizationHint> {
1530    let signatures = plugin_signatures();
1531    let bool_plugins = boolean_plugins();
1532    let mode = normalization_mode();
1533    let mut hints = Vec::new();
1534
1535    for section in &doc.sections {
1536        if section.section_type != parser::ast::SectionType::Asserts {
1537            continue;
1538        }
1539
1540        for (idx, line) in section.raw_content.lines().enumerate() {
1541            let Some(trimmed) = strip_assertion_comments(line) else {
1542                continue;
1543            };
1544
1545            if !likely_needs_assertion_rewrite(&trimmed) {
1546                continue;
1547            }
1548
1549            if let Some((rule_id, rewrite)) = rewrite_assertion_expression_with_context(
1550                &trimmed,
1551                signatures,
1552                bool_plugins,
1553                mode,
1554                level,
1555            ) {
1556                debug_assert!(rule_metadata(rule_id).is_some());
1557                hints.push(build_hint(
1558                    rule_id,
1559                    section_content_line(section.start_line, idx),
1560                    &trimmed,
1561                    rewrite,
1562                ));
1563            }
1564        }
1565    }
1566
1567    hints
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572    use super::*;
1573
1574    fn ast_mode_active_for_tests() -> bool {
1575        matches!(normalization_mode(), NormalizationMode::AstCanonical)
1576    }
1577
1578    #[test]
1579    fn test_collect_assertion_optimizations_detects_boolean_rewrite() {
1580        let content = r#"--- ENDPOINT ---
1581test.Service/Method
1582
1583--- ASSERTS ---
1584@has_header("x-request-id") == true
1585"#;
1586
1587        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1588        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1589        assert_eq!(hints.len(), 1);
1590        assert_eq!(hints[0].rule_id, rule_ids::B001);
1591        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1592    }
1593
1594    #[test]
1595    fn test_collect_assertion_optimizations_detects_double_negation_rewrite() {
1596        let content = r#"--- ENDPOINT ---
1597test.Service/Method
1598
1599--- ASSERTS ---
1600!!@has_header("x-request-id")
1601"#;
1602
1603        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1604        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1605        assert_eq!(hints.len(), 1);
1606        if ast_mode_active_for_tests() {
1607            assert_eq!(hints[0].rule_id, rule_ids::B017);
1608        } else {
1609            assert_eq!(hints[0].rule_id, rule_ids::B005);
1610        }
1611        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1612    }
1613
1614    #[test]
1615    fn test_collect_assertion_optimizations_detects_operator_canonicalization() {
1616        let content = r#"--- ENDPOINT ---
1617test.Service/Method
1618
1619--- ASSERTS ---
1620.name startswith "abc"
1621"#;
1622
1623        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1624        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1625        if ast_mode_active_for_tests() {
1626            assert!(hints.is_empty());
1627        } else {
1628            assert_eq!(hints.len(), 1);
1629            assert_eq!(hints[0].rule_id, rule_ids::N001);
1630            assert_eq!(hints[0].after, ".name startsWith \"abc\"");
1631        }
1632    }
1633
1634    #[test]
1635    fn test_collect_assertion_optimizations_no_double_negation_for_non_boolean_plugin() {
1636        let content = r#"--- ENDPOINT ---
1637test.Service/Method
1638
1639--- ASSERTS ---
1640!!@len(.items)
1641"#;
1642
1643        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1644        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1645        assert!(hints.is_empty());
1646    }
1647
1648    #[test]
1649    fn test_collect_assertion_optimizations_constant_fold_numeric_compare() {
1650        let content = r#"--- ENDPOINT ---
1651test.Service/Method
1652
1653--- ASSERTS ---
16541 + 1 == 2
16553 > 2
1656"#;
1657
1658        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1659        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1660
1661        // Only '3 > 2' is a strict literal compare and safe to fold here.
1662        assert_eq!(hints.len(), 1);
1663        assert_eq!(hints[0].rule_id, rule_ids::B006);
1664        assert_eq!(hints[0].before, "3 > 2");
1665        assert_eq!(hints[0].after, "true");
1666    }
1667
1668    #[test]
1669    fn test_collect_assertion_optimizations_constant_fold_string_equality() {
1670        let content = r#"--- ENDPOINT ---
1671test.Service/Method
1672
1673--- ASSERTS ---
1674"a" == "a"
1675"#;
1676
1677        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1678        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1679        assert_eq!(hints.len(), 1);
1680        assert_eq!(hints[0].rule_id, rule_ids::B006);
1681        assert_eq!(hints[0].after, "true");
1682    }
1683
1684    #[test]
1685    fn test_rewrite_rule_metadata_is_complete() {
1686        let expected = [
1687            rule_ids::B001,
1688            rule_ids::B002,
1689            rule_ids::B003,
1690            rule_ids::B004,
1691            rule_ids::B005,
1692            rule_ids::B006,
1693            rule_ids::B007,
1694            rule_ids::B008,
1695            rule_ids::B009,
1696            rule_ids::B010,
1697            rule_ids::B013,
1698            rule_ids::B014,
1699            rule_ids::B015,
1700            rule_ids::B016,
1701            rule_ids::B017,
1702            rule_ids::N001,
1703            rule_ids::N002,
1704            rule_ids::I001,
1705            rule_ids::I002,
1706            rule_ids::I003,
1707            rule_ids::I004,
1708            rule_ids::I005,
1709            rule_ids::P001,
1710            rule_ids::P002,
1711            rule_ids::T001,
1712            rule_ids::T002,
1713            rule_ids::R001,
1714            rule_ids::R002,
1715        ];
1716
1717        for id in expected {
1718            let meta = rule_metadata(id).unwrap_or_else(|| panic!("missing metadata for {id}"));
1719            assert!(!meta.preconditions.is_empty());
1720            assert!(!meta.negative_cases.is_empty());
1721            assert!(!meta.proof_note.is_empty());
1722        }
1723    }
1724
1725    #[test]
1726    fn test_optimization_hint_contains_rule_metadata() {
1727        let content = r#"--- ENDPOINT ---
1728test.Service/Method
1729
1730--- ASSERTS ---
1731@has_header("x") == true
1732"#;
1733
1734        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1735        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1736        assert_eq!(hints.len(), 1);
1737        assert!(hints[0].preconditions.as_deref().is_some());
1738        assert!(hints[0].negative_cases.as_deref().is_some());
1739        assert!(hints[0].proof_note.as_deref().is_some());
1740    }
1741
1742    #[test]
1743    fn test_collect_assertion_optimizations_reflexive_idempotent_path() {
1744        let content = r#"--- ENDPOINT ---
1745test.Service/Method
1746
1747--- ASSERTS ---
1748.user.id == .user.id
1749"#;
1750
1751        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1752        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1753
1754        assert_eq!(hints.len(), 1);
1755        assert_eq!(hints[0].rule_id, rule_ids::B007);
1756        assert_eq!(hints[0].after, "true");
1757    }
1758
1759    #[test]
1760    fn test_collect_assertion_optimizations_no_reflexive_for_non_idempotent_plugin() {
1761        let content = r#"--- ENDPOINT ---
1762test.Service/Method
1763
1764--- ASSERTS ---
1765@env("HOME") == @env("HOME")
1766"#;
1767
1768        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1769        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1770
1771        assert!(hints.is_empty());
1772    }
1773
1774    #[test]
1775    fn test_collect_assertion_optimizations_reflexive_idempotent_inequality() {
1776        let content = r#"--- ENDPOINT ---
1777test.Service/Method
1778
1779--- ASSERTS ---
1780$user_id != $user_id
1781"#;
1782
1783        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1784        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
1785
1786        assert_eq!(hints.len(), 1);
1787        assert_eq!(hints[0].rule_id, rule_ids::B008);
1788        assert_eq!(hints[0].after, "false");
1789    }
1790
1791    #[test]
1792    fn test_rewrite_assertion_expression_fixed_point() {
1793        let expr = "true == @has_header(\"x-request-id\")";
1794        let rewritten = rewrite_assertion_expression_fixed_point(expr);
1795        assert_eq!(rewritten, "@has_header(\"x-request-id\")");
1796    }
1797
1798    #[test]
1799    fn test_rewrite_assertion_expression_fixed_point_if_changed() {
1800        assert_eq!(
1801            rewrite_assertion_expression_fixed_point_if_changed(
1802                "true == @has_header(\"x-request-id\")"
1803            ),
1804            Some("@has_header(\"x-request-id\")".to_string())
1805        );
1806        assert_eq!(
1807            rewrite_assertion_expression_fixed_point_if_changed(".status == 200"),
1808            None
1809        );
1810    }
1811
1812    #[test]
1813    fn test_collect_assertion_optimizations_ignores_inline_comments() {
1814        let content = r#"--- ENDPOINT ---
1815test.Service/Method
1816
1817--- ASSERTS ---
1818true == @has_header("x-request-id") // comment should be ignored
1819"#;
1820
1821        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1822        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1823        assert_eq!(hints.len(), 1);
1824        assert_eq!(hints[0].rule_id, rule_ids::B003);
1825        assert_eq!(hints[0].after, "@has_header(\"x-request-id\")");
1826    }
1827
1828    #[test]
1829    fn test_likely_needs_assertion_rewrite_fast_path() {
1830        // Both old and new scope syntax trigger rewrite (contains @ and ==)
1831        assert!(likely_needs_assertion_rewrite("@scope_message_count()"));
1832        assert!(likely_needs_assertion_rewrite(
1833            "@scope.message_count() == 2"
1834        ));
1835        assert!(likely_needs_assertion_rewrite("@elapsed_ms() >= 10"));
1836        assert!(likely_needs_assertion_rewrite("true == @has_header(\"x\")"));
1837        assert!(likely_needs_assertion_rewrite(".name startswith \"abc\""));
1838        assert!(likely_needs_assertion_rewrite("if true then 1 else 2 end"));
1839    }
1840
1841    // === If-then-else optimization tests ===
1842
1843    #[test]
1844    fn test_dead_branch_elimination_true() {
1845        let (rule_id, rewritten) = suggest_dead_branch_elimination(
1846            "if true then \"yes\" else \"no\" end",
1847            OptimizeLevel::Advisory,
1848        )
1849        .unwrap();
1850        assert_eq!(rule_id, rule_ids::I001);
1851        assert_eq!(rewritten, "\"yes\"");
1852    }
1853
1854    #[test]
1855    fn test_dead_branch_elimination_false() {
1856        let (rule_id, rewritten) = suggest_dead_branch_elimination(
1857            "if false then \"yes\" else \"no\" end",
1858            OptimizeLevel::Advisory,
1859        )
1860        .unwrap();
1861        assert_eq!(rule_id, rule_ids::I001);
1862        assert_eq!(rewritten, "\"no\"");
1863    }
1864
1865    #[test]
1866    fn test_branch_merging() {
1867        let (rule_id, rewritten) = suggest_branch_merging(
1868            "if .x > 0 then \"same\" else \"same\" end",
1869            OptimizeLevel::Advisory,
1870        )
1871        .unwrap();
1872        assert_eq!(rule_id, rule_ids::I002);
1873        assert_eq!(rewritten, "\"same\"");
1874    }
1875
1876    #[test]
1877    fn test_nested_if_simplification() {
1878        // Pattern: if A then (if A then X else Y end) else Z end
1879        // Simplified: if A then X else Z end
1880        let input =
1881            "if .a > 0 then (if .a > 0 then \"inner\" else \"other\" end) else \"outer\" end";
1882        let result = suggest_nested_if_simplification(input, OptimizeLevel::Advisory);
1883        assert!(result.is_some());
1884        let (rule_id, rewritten) = result.unwrap();
1885        assert_eq!(rule_id, rule_ids::I003);
1886        assert_eq!(rewritten, "if .a > 0 then \"inner\" else \"outer\" end");
1887    }
1888
1889    #[test]
1890    fn test_parse_if_then_else_simple() {
1891        let (cond, then_expr, else_expr) =
1892            parse_if_then_else("if .x > 0 then \"yes\" else \"no\" end").unwrap();
1893        assert_eq!(cond, ".x > 0");
1894        assert_eq!(then_expr, "\"yes\"");
1895        assert_eq!(else_expr, "\"no\"");
1896    }
1897
1898    #[test]
1899    fn test_parse_if_then_else_nested() {
1900        let (cond, then_expr, else_expr) = parse_if_then_else(
1901            "if .a > 0 then (if .b > 0 then \"both\" else \"a only\" end) else \"none\" end",
1902        )
1903        .unwrap();
1904        assert_eq!(cond, ".a > 0");
1905        assert_eq!(then_expr, "(if .b > 0 then \"both\" else \"a only\" end)");
1906        assert_eq!(else_expr, "\"none\"");
1907    }
1908
1909    #[test]
1910    fn test_collect_optimizations_detects_dead_branch() {
1911        let content = r#"--- ENDPOINT ---
1912test.Service/Method
1913
1914--- ASSERTS ---
1915if true then "always" else "never" end
1916"#;
1917
1918        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1919        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1920        assert_eq!(hints.len(), 1);
1921        assert_eq!(hints[0].rule_id, rule_ids::I001);
1922        assert_eq!(hints[0].after, "\"always\"");
1923    }
1924
1925    #[test]
1926    fn test_collect_optimizations_detects_branch_merging() {
1927        let content = r#"--- ENDPOINT ---
1928test.Service/Method
1929
1930--- ASSERTS ---
1931if .x > 0 then "same" else "same" end
1932"#;
1933
1934        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
1935        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
1936        assert_eq!(hints.len(), 1);
1937        assert_eq!(hints[0].rule_id, rule_ids::I002);
1938        assert_eq!(hints[0].after, "\"same\"");
1939    }
1940
1941    #[test]
1942    fn test_boolean_simplification() {
1943        let (rule_id, rewritten) = suggest_boolean_simplification(
1944            "if .x > 0 then true else false end",
1945            OptimizeLevel::Advisory,
1946        )
1947        .unwrap();
1948        assert_eq!(rule_id, rule_ids::I004);
1949        assert_eq!(rewritten, ".x > 0");
1950    }
1951
1952    #[test]
1953    fn test_condition_inversion() {
1954        let (rule_id, rewritten) = suggest_condition_inversion(
1955            "if .x > 0 then false else true end",
1956            OptimizeLevel::Advisory,
1957        )
1958        .unwrap();
1959        assert_eq!(rule_id, rule_ids::I005);
1960        assert_eq!(rewritten, ".x <= 0");
1961    }
1962
1963    #[test]
1964    fn test_condition_inversion_contains_needs_parens() {
1965        let (rule_id, rewritten) = suggest_condition_inversion(
1966            "if .name contains \"foo\" then false else true end",
1967            OptimizeLevel::Advisory,
1968        )
1969        .unwrap();
1970        assert_eq!(rule_id, rule_ids::I005);
1971        assert_eq!(rewritten, "!(.name contains \"foo\")");
1972    }
1973
1974    #[test]
1975    fn test_condition_inversion_simple_plugin_call_no_parens() {
1976        let (rule_id, rewritten) = suggest_condition_inversion(
1977            "if @has_header(\"x\") then false else true end",
1978            OptimizeLevel::Advisory,
1979        )
1980        .unwrap();
1981        assert_eq!(rule_id, rule_ids::I005);
1982        assert_eq!(rewritten, "!@has_header(\"x\")");
1983    }
1984
1985    #[test]
1986    fn test_condition_inversion_not_keyword_gets_grouped() {
1987        let (rule_id, rewritten) = suggest_condition_inversion(
1988            "if not @has_header(\"x\") then false else true end",
1989            OptimizeLevel::Advisory,
1990        )
1991        .unwrap();
1992        assert_eq!(rule_id, rule_ids::I005);
1993        assert_eq!(rewritten, "!(not @has_header(\"x\"))");
1994    }
1995
1996    #[test]
1997    fn test_condition_inversion_bang_gets_grouped() {
1998        let (rule_id, rewritten) = suggest_condition_inversion(
1999            "if !@has_header(\"x\") then false else true end",
2000            OptimizeLevel::Advisory,
2001        )
2002        .unwrap();
2003        assert_eq!(rule_id, rule_ids::I005);
2004        assert_eq!(rewritten, "!(!@has_header(\"x\"))");
2005    }
2006
2007    #[test]
2008    fn test_condition_inversion_matches_gets_grouped() {
2009        let (rule_id, rewritten) = suggest_condition_inversion(
2010            "if .name matches /foo.*/ then false else true end",
2011            OptimizeLevel::Advisory,
2012        )
2013        .unwrap();
2014        assert_eq!(rule_id, rule_ids::I005);
2015        assert_eq!(rewritten, "!(.name matches /foo.*/)");
2016    }
2017
2018    #[test]
2019    fn test_collect_optimizations_boolean_simplification() {
2020        let content = r#"--- ENDPOINT ---
2021test.Service/Method
2022
2023--- ASSERTS ---
2024if @has_header("x") then true else false end
2025"#;
2026
2027        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2028        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2029        assert_eq!(hints.len(), 1);
2030        assert_eq!(hints[0].rule_id, rule_ids::I004);
2031        assert_eq!(hints[0].after, "@has_header(\"x\")");
2032    }
2033
2034    #[test]
2035    fn test_collect_optimizations_condition_inversion() {
2036        let content = r#"--- ENDPOINT ---
2037test.Service/Method
2038
2039--- ASSERTS ---
2040if .status == 200 then false else true end
2041"#;
2042
2043        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2044        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2045        assert_eq!(hints.len(), 1);
2046        assert_eq!(hints[0].rule_id, rule_ids::I005);
2047        assert_eq!(hints[0].after, ".status != 200");
2048    }
2049
2050    #[test]
2051    fn test_parse_if_then_else_string_with_else_keyword() {
2052        let (cond, then_expr, else_expr) =
2053            parse_if_then_else(r#"if true then " else " else "no" end"#).unwrap();
2054        assert_eq!(cond, "true");
2055        assert_eq!(then_expr, r#"" else ""#);
2056        assert_eq!(else_expr, r#""no""#);
2057    }
2058
2059    #[test]
2060    fn test_parse_if_then_else_then_in_string_condition() {
2061        let (cond, then_expr, else_expr) =
2062            parse_if_then_else(r#"if .x == "then" then "yes" else "no" end"#).unwrap();
2063        assert_eq!(cond, r#".x == "then""#);
2064        assert_eq!(then_expr, r#""yes""#);
2065        assert_eq!(else_expr, r#""no""#);
2066    }
2067
2068    // === New optimization rules tests ===
2069
2070    #[test]
2071    fn test_boolean_identity_or() {
2072        // A or true = true
2073        let (rule_id, rewritten) =
2074            suggest_boolean_identity_laws(".x or true", OptimizeLevel::Advisory).unwrap();
2075        assert_eq!(rule_id, rule_ids::B009);
2076        assert_eq!(rewritten, "true");
2077
2078        // A or false = A
2079        let (rule_id, rewritten) =
2080            suggest_boolean_identity_laws(".x or false", OptimizeLevel::Advisory).unwrap();
2081        assert_eq!(rule_id, rule_ids::B009);
2082        assert_eq!(rewritten, ".x");
2083
2084        // true or A = true
2085        let (rule_id, rewritten) =
2086            suggest_boolean_identity_laws("true or .x", OptimizeLevel::Advisory).unwrap();
2087        assert_eq!(rule_id, rule_ids::B009);
2088        assert_eq!(rewritten, "true");
2089    }
2090
2091    #[test]
2092    fn test_boolean_absorption_and() {
2093        // A and true = A
2094        let (rule_id, rewritten) =
2095            suggest_boolean_identity_laws(".x and true", OptimizeLevel::Advisory).unwrap();
2096        assert_eq!(rule_id, rule_ids::B010);
2097        assert_eq!(rewritten, ".x");
2098
2099        // A and false = false
2100        let (rule_id, rewritten) =
2101            suggest_boolean_identity_laws(".x and false", OptimizeLevel::Advisory).unwrap();
2102        assert_eq!(rule_id, rule_ids::B010);
2103        assert_eq!(rewritten, "false");
2104
2105        // false and A = false
2106        let (rule_id, rewritten) =
2107            suggest_boolean_identity_laws("false and .x", OptimizeLevel::Advisory).unwrap();
2108        assert_eq!(rule_id, rule_ids::B010);
2109        assert_eq!(rewritten, "false");
2110    }
2111
2112    #[test]
2113    fn test_plugin_length_simplification() {
2114        // @len(.x) == 0 → @empty(.x)
2115        let (rule_id, rewritten) =
2116            suggest_plugin_length_simplification("@len(.items) == 0", OptimizeLevel::Advisory)
2117                .unwrap();
2118        assert_eq!(rule_id, rule_ids::P001);
2119        assert_eq!(rewritten, "@empty(.items)");
2120
2121        // @len(.x) != 0 → @len(.x) > 0
2122        let (rule_id, rewritten) =
2123            suggest_plugin_length_simplification("@len(.items) != 0", OptimizeLevel::Advisory)
2124                .unwrap();
2125        assert_eq!(rule_id, rule_ids::P001);
2126        assert_eq!(rewritten, "@len(.items) > 0");
2127
2128        // @len(.x) > 0 → no simplification
2129        let result =
2130            suggest_plugin_length_simplification("@len(.items) > 0", OptimizeLevel::Advisory);
2131        assert!(result.is_none());
2132
2133        // 0 == @len(.x) → @empty(.x)
2134        let (rule_id, rewritten) =
2135            suggest_plugin_length_simplification("0 == @len(.items)", OptimizeLevel::Advisory)
2136                .unwrap();
2137        assert_eq!(rule_id, rule_ids::P001);
2138        assert_eq!(rewritten, "@empty(.items)");
2139    }
2140
2141    #[test]
2142    fn test_comparison_negation() {
2143        // not (.x == 5) → .x != 5
2144        let (rule_id, rewritten) =
2145            suggest_comparison_negation("not (.x == 5)", OptimizeLevel::Advisory).unwrap();
2146        assert_eq!(rule_id, rule_ids::N002);
2147        assert_eq!(rewritten, ".x != 5");
2148
2149        // not (.x != 5) → .x == 5
2150        let (rule_id, rewritten) =
2151            suggest_comparison_negation("not (.x != 5)", OptimizeLevel::Advisory).unwrap();
2152        assert_eq!(rule_id, rule_ids::N002);
2153        assert_eq!(rewritten, ".x == 5");
2154
2155        // not (.x > 5) → .x <= 5
2156        let (rule_id, rewritten) =
2157            suggest_comparison_negation("not (.x > 5)", OptimizeLevel::Advisory).unwrap();
2158        assert_eq!(rule_id, rule_ids::N002);
2159        assert_eq!(rewritten, ".x <= 5");
2160
2161        // not (.x >= 5) → .x < 5
2162        let (rule_id, rewritten) =
2163            suggest_comparison_negation("not (.x >= 5)", OptimizeLevel::Advisory).unwrap();
2164        assert_eq!(rule_id, rule_ids::N002);
2165        assert_eq!(rewritten, ".x < 5");
2166
2167        // !(.x <= 5) -> .x > 5
2168        let (rule_id, rewritten) =
2169            suggest_comparison_negation("!(.x <= 5)", OptimizeLevel::Advisory).unwrap();
2170        assert_eq!(rule_id, rule_ids::N002);
2171        assert_eq!(rewritten, ".x > 5");
2172
2173        // malformed/non-comparison inner should not rewrite
2174        assert!(suggest_comparison_negation("!(.x)", OptimizeLevel::Advisory).is_none());
2175    }
2176
2177    #[test]
2178    fn test_collect_optimizations_boolean_identity() {
2179        let content = r#"--- ENDPOINT ---
2180test.Service/Method
2181
2182--- ASSERTS ---
2183@has_header("x") or true
2184"#;
2185
2186        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2187        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2188        assert_eq!(hints.len(), 1);
2189        assert_eq!(hints[0].rule_id, rule_ids::B009);
2190        assert_eq!(hints[0].after, "true");
2191    }
2192
2193    #[test]
2194    fn test_collect_optimizations_plugin_length() {
2195        let content = r#"--- ENDPOINT ---
2196test.Service/Method
2197
2198--- ASSERTS ---
2199@len(.items) == 0
2200"#;
2201
2202        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2203        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2204        assert_eq!(hints.len(), 1);
2205        assert_eq!(hints[0].rule_id, rule_ids::P001);
2206        assert_eq!(hints[0].after, "@empty(.items)");
2207    }
2208
2209    #[test]
2210    fn test_collect_optimizations_type_aware_uint_gte_zero() {
2211        let content = r#"--- ENDPOINT ---
2212test.Service/Method
2213
2214--- ASSERTS ---
2215@len(.items) >= 0
2216"#;
2217
2218        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2219        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Aggressive);
2220        assert_eq!(hints.len(), 1);
2221        assert_eq!(hints[0].rule_id, rule_ids::T001);
2222        assert_eq!(hints[0].after, "true");
2223    }
2224
2225    #[test]
2226    fn test_collect_optimizations_comparison_negation() {
2227        let content = r#"--- ENDPOINT ---
2228test.Service/Method
2229
2230--- ASSERTS ---
2231not (.status == 200)
2232"#;
2233
2234        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2235        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2236        assert_eq!(hints.len(), 1);
2237        assert_eq!(hints[0].rule_id, rule_ids::N002);
2238        assert_eq!(hints[0].after, ".status != 200");
2239    }
2240
2241    #[test]
2242    fn test_collect_optimizations_b002_expr_equals_false() {
2243        let content = r#"--- ENDPOINT ---
2244test.Service/Method
2245
2246--- ASSERTS ---
2247@has_header("x") == false
2248"#;
2249        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2250        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2251        assert_eq!(hints.len(), 1);
2252        assert_eq!(hints[0].rule_id, rule_ids::B002);
2253        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2254    }
2255
2256    #[test]
2257    fn test_collect_optimizations_b004_false_equals_expr() {
2258        let content = r#"--- ENDPOINT ---
2259test.Service/Method
2260
2261--- ASSERTS ---
2262false == @has_header("x")
2263"#;
2264        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2265        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2266        assert_eq!(hints.len(), 1);
2267        assert_eq!(hints[0].rule_id, rule_ids::B004);
2268        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2269    }
2270
2271    #[test]
2272    fn test_collect_optimizations_b013_inequality_true() {
2273        let content = r#"--- ENDPOINT ---
2274test.Service/Method
2275
2276--- ASSERTS ---
2277@has_header("x") != true
2278"#;
2279        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2280        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2281        assert_eq!(hints.len(), 1);
2282        assert_eq!(hints[0].rule_id, rule_ids::B013);
2283        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2284    }
2285
2286    #[test]
2287    fn test_collect_optimizations_b014_inequality_false() {
2288        let content = r#"--- ENDPOINT ---
2289test.Service/Method
2290
2291--- ASSERTS ---
2292@has_header("x") != false
2293"#;
2294        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2295        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2296        assert_eq!(hints.len(), 1);
2297        assert_eq!(hints[0].rule_id, rule_ids::B014);
2298        assert_eq!(hints[0].after, "@has_header(\"x\")");
2299    }
2300
2301    #[test]
2302    fn test_collect_optimizations_b015_true_inequality() {
2303        let content = r#"--- ENDPOINT ---
2304test.Service/Method
2305
2306--- ASSERTS ---
2307true != @has_header("x")
2308"#;
2309        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2310        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2311        assert_eq!(hints.len(), 1);
2312        assert_eq!(hints[0].rule_id, rule_ids::B015);
2313        assert_eq!(hints[0].after, "!@has_header(\"x\")");
2314    }
2315
2316    #[test]
2317    fn test_collect_optimizations_b016_false_inequality() {
2318        let content = r#"--- ENDPOINT ---
2319test.Service/Method
2320
2321--- ASSERTS ---
2322false != @has_header("x")
2323"#;
2324        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2325        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2326        assert_eq!(hints.len(), 1);
2327        assert_eq!(hints[0].rule_id, rule_ids::B016);
2328        assert_eq!(hints[0].after, "@has_header(\"x\")");
2329    }
2330
2331    #[test]
2332    fn test_collect_optimizations_b017_double_not_word() {
2333        let content = r#"--- ENDPOINT ---
2334test.Service/Method
2335
2336--- ASSERTS ---
2337not not @has_header("x")
2338"#;
2339        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2340        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2341        assert_eq!(hints.len(), 1);
2342        assert_eq!(hints[0].rule_id, rule_ids::B017);
2343        assert_eq!(hints[0].after, "@has_header(\"x\")");
2344    }
2345
2346    #[test]
2347    fn test_collect_optimizations_p002_redundant_parens() {
2348        let result = rewrite_assertion_expression_fixed_point("(@has_header(\"x\"))");
2349        if ast_mode_active_for_tests() {
2350            assert_eq!(result, "(@has_header(\"x\"))");
2351        } else {
2352            assert_eq!(result, "@has_header(\"x\")");
2353        }
2354    }
2355
2356    #[test]
2357    fn test_boolean_plugins_contains_uuid() {
2358        let bp = boolean_plugins();
2359        assert!(bp.contains("uuid"));
2360        assert!(bp.contains("email"));
2361        assert!(bp.contains("empty"));
2362    }
2363
2364    #[test]
2365    fn test_plugin_signatures_returns_map() {
2366        let sigs = plugin_signatures();
2367        assert!(!sigs.is_empty());
2368        assert!(sigs.contains_key("uuid"));
2369    }
2370
2371    #[test]
2372    fn test_is_boolean_plugin_expr() {
2373        let bp = boolean_plugins();
2374        assert!(is_boolean_plugin_expr("@uuid(.x)", bp));
2375        assert!(is_boolean_plugin_expr("@empty(.items)", bp));
2376        assert!(!is_boolean_plugin_expr("@len(.x)", bp));
2377    }
2378
2379    #[test]
2380    fn test_suggest_constant_folding_string_equality() {
2381        let result = suggest_constant_folding("\"foo\" == \"foo\"", OptimizeLevel::Aggressive);
2382        assert!(result.is_some());
2383        let (rule_id, after) = result.unwrap();
2384        assert_eq!(rule_id, rule_ids::B006);
2385        assert_eq!(after, "true");
2386    }
2387
2388    #[test]
2389    fn test_suggest_constant_folding_mixed_types() {
2390        let result = suggest_constant_folding("\"foo\" == 123", OptimizeLevel::Aggressive);
2391        assert!(result.is_some());
2392        let (_rule_id, after) = result.unwrap();
2393        assert_eq!(after, "false");
2394    }
2395
2396    #[test]
2397    fn test_suggest_constant_folding_invalid_json() {
2398        let result = suggest_constant_folding("@len(.x) == 5", OptimizeLevel::Aggressive);
2399        assert!(result.is_none());
2400    }
2401
2402    #[test]
2403    fn test_normalization_mode_is_ast_canonical() {
2404        assert_eq!(normalization_mode(), NormalizationMode::AstCanonical);
2405    }
2406
2407    #[test]
2408    fn test_ast_mode_can_change_first_matching_rule() {
2409        let signatures = plugin_signatures();
2410        let bool_plugins = boolean_plugins();
2411        let expr = "((@has_header(\"x\"))) == true";
2412
2413        let conservative = rewrite_assertion_expression_with_context(
2414            expr,
2415            signatures,
2416            bool_plugins,
2417            NormalizationMode::Conservative,
2418            OptimizeLevel::Advisory,
2419        );
2420        let ast = rewrite_assertion_expression_with_context(
2421            expr,
2422            signatures,
2423            bool_plugins,
2424            NormalizationMode::AstCanonical,
2425            OptimizeLevel::Advisory,
2426        );
2427
2428        assert_eq!(conservative.map(|(id, _)| id), None);
2429        assert_eq!(ast.map(|(id, _)| id), Some(rule_ids::B001));
2430    }
2431
2432    #[test]
2433    fn test_ast_canonical_mode_preserves_execution_result() {
2434        use apif_assert::engine::{AssertionEngine, AssertionResult};
2435        use serde_json::json;
2436
2437        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2438        enum Outcome {
2439            Pass,
2440            Fail,
2441            Error,
2442        }
2443
2444        fn outcome_of(result: &AssertionResult) -> Outcome {
2445            match result {
2446                AssertionResult::Pass => Outcome::Pass,
2447                AssertionResult::Fail { .. } => Outcome::Fail,
2448                AssertionResult::Error(_) => Outcome::Error,
2449            }
2450        }
2451
2452        let engine =
2453            AssertionEngine::with_registry(std::sync::Arc::new(apif_plugins::PluginManager::new()));
2454        let cases = [
2455            "!!@has_header(\"x\")",
2456            "not not @has_header(\"x\")",
2457            "@has_header(\"x\") == true",
2458            "@has_header(\"x\") == false",
2459            "true != @has_header(\"x\")",
2460            ".name startswith \"abc\"",
2461            "not (.status == 200)",
2462            "if @has_header(\"x\") then true else false end",
2463            "if .status == 200 then false else true end",
2464            "if true then \"always\" else \"never\" end",
2465            "if .x > 0 then \"same\" else \"same\" end",
2466            "(@has_header(\"x\"))",
2467            "@len(.items) >= 0",
2468            "@len(.items) == 0",
2469            "@has_header(\"x\") == true and .status == 200",
2470            "true or @has_header(\"x\")",
2471        ];
2472
2473        let contexts = vec![
2474            (
2475                "status_200_with_header",
2476                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2] }),
2477                Some(std::collections::HashMap::from([(
2478                    "x".to_string(),
2479                    "1".to_string(),
2480                )])),
2481            ),
2482            (
2483                "status_200_without_header",
2484                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2] }),
2485                None,
2486            ),
2487            (
2488                "status_500_without_header",
2489                json!({ "status": 500, "name": "zzz", "x": 0, "items": [] }),
2490                None,
2491            ),
2492        ];
2493
2494        for (ctx_name, response, headers_owned) in contexts {
2495            let headers_ref = headers_owned.as_ref();
2496            for expr in cases {
2497                let conservative = rewrite_assertion_expression_fixed_point_with_mode(
2498                    expr,
2499                    NormalizationMode::Conservative,
2500                    OptimizeLevel::Advisory,
2501                );
2502                let ast = rewrite_assertion_expression_fixed_point_with_mode(
2503                    expr,
2504                    NormalizationMode::AstCanonical,
2505                    OptimizeLevel::Advisory,
2506                );
2507
2508                let before = engine.evaluate(expr, &response, headers_ref, None).unwrap();
2509                let after_conservative = engine
2510                    .evaluate(&conservative, &response, headers_ref, None)
2511                    .unwrap();
2512                let after_ast = engine.evaluate(&ast, &response, headers_ref, None).unwrap();
2513
2514                let before_outcome = outcome_of(&before);
2515                let conservative_outcome = outcome_of(&after_conservative);
2516                let ast_outcome = outcome_of(&after_ast);
2517
2518                assert_eq!(
2519                    before_outcome, conservative_outcome,
2520                    "conservative rewrite changed outcome in {ctx_name}: {expr} -> {conservative}",
2521                );
2522                assert_eq!(
2523                    before_outcome, ast_outcome,
2524                    "ast rewrite changed outcome in {ctx_name}: {expr} -> {ast}",
2525                );
2526
2527                let conservative_twice = rewrite_assertion_expression_fixed_point_with_mode(
2528                    &conservative,
2529                    NormalizationMode::Conservative,
2530                    OptimizeLevel::Advisory,
2531                );
2532                let ast_twice = rewrite_assertion_expression_fixed_point_with_mode(
2533                    &ast,
2534                    NormalizationMode::AstCanonical,
2535                    OptimizeLevel::Advisory,
2536                );
2537                assert_eq!(
2538                    conservative, conservative_twice,
2539                    "conservative rewrite not idempotent in {ctx_name}: {expr}",
2540                );
2541                assert_eq!(
2542                    ast, ast_twice,
2543                    "ast rewrite not idempotent in {ctx_name}: {expr}",
2544                );
2545
2546                let default_path = rewrite_assertion_expression_fixed_point(expr);
2547                assert_eq!(
2548                    default_path, ast,
2549                    "default rewrite diverged from ast mode in {ctx_name}: {expr}",
2550                );
2551            }
2552        }
2553    }
2554
2555    #[test]
2556    fn test_optimizer_hints_preserve_execution_result() {
2557        use apif_assert::engine::{AssertionEngine, AssertionResult};
2558        use serde_json::json;
2559
2560        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2561        enum Outcome {
2562            Pass,
2563            Fail,
2564            Error,
2565        }
2566
2567        fn outcome_of(result: &AssertionResult) -> Outcome {
2568            match result {
2569                AssertionResult::Pass => Outcome::Pass,
2570                AssertionResult::Fail { .. } => Outcome::Fail,
2571                AssertionResult::Error(_) => Outcome::Error,
2572            }
2573        }
2574
2575        let content = r#"--- ENDPOINT ---
2576test.Service/Method
2577
2578--- ASSERTS ---
2579@has_header("x") == true
2580@has_header("x") == false
2581false == @has_header("x")
2582@has_header("x") != true
2583!!@has_header("x")
2584not not @has_header("x")
2585.name startswith "abc"
25863 > 2
2587.user.id == .user.id
2588$user_id != $user_id
2589if true then "always" else "never" end
2590if .x > 0 then "same" else "same" end
2591if @has_header("x") then true else false end
2592if .status == 200 then false else true end
2593@len(.items) == 0
2594(@has_header("x"))
2595not (.status == 200)
2596@len(.items) >= 0
2597@has_header("x") or true
2598"#;
2599
2600        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2601        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2602        assert!(!hints.is_empty());
2603
2604        let engine =
2605            AssertionEngine::with_registry(std::sync::Arc::new(apif_plugins::PluginManager::new()));
2606        let contexts = vec![
2607            (
2608                "status_200_with_header",
2609                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2], "user": { "id": 1 } }),
2610                Some(std::collections::HashMap::from([(
2611                    "x".to_string(),
2612                    "1".to_string(),
2613                )])),
2614            ),
2615            (
2616                "status_200_without_header",
2617                json!({ "status": 200, "name": "abc-xyz", "x": 1, "items": [1, 2], "user": { "id": 1 } }),
2618                None,
2619            ),
2620            (
2621                "status_500_without_header",
2622                json!({ "status": 500, "name": "zzz", "x": 0, "items": [], "user": { "id": 1 } }),
2623                None,
2624            ),
2625        ];
2626
2627        for hint in hints {
2628            for (ctx_name, response, headers_owned) in &contexts {
2629                let headers_ref = headers_owned.as_ref();
2630                let before = engine
2631                    .evaluate(&hint.before, response, headers_ref, None)
2632                    .unwrap();
2633                let after = engine
2634                    .evaluate(&hint.after, response, headers_ref, None)
2635                    .unwrap();
2636
2637                assert_eq!(
2638                    outcome_of(&before),
2639                    outcome_of(&after),
2640                    "rule {} changed outcome in {ctx_name}: '{}' -> '{}'",
2641                    hint.rule_id,
2642                    hint.before,
2643                    hint.after,
2644                );
2645            }
2646        }
2647    }
2648
2649    // ─── Redundant type cast tests ───────────────────────────────────
2650
2651    #[test]
2652    fn test_suggest_redundant_type_cast_len_uint() {
2653        let expr = "@len(.items):uint >= 0";
2654        let signatures = plugin_signatures();
2655        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2656        assert!(result.is_some(), "Expected redundant cast for @len(:uint)");
2657        if let Some((rule_id, rewritten)) = result {
2658            assert_eq!(rule_id, rule_ids::T002);
2659            assert_eq!(rewritten, "@len(.items) >= 0");
2660        }
2661    }
2662
2663    #[test]
2664    fn test_suggest_redundant_type_cast_header_string() {
2665        // @header returns String, so :string is redundant
2666        let expr = "@header(\"x\"):string != null";
2667        let signatures = plugin_signatures();
2668        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2669        assert!(
2670            result.is_some(),
2671            "Expected redundant cast for @header(:string)"
2672        );
2673        if let Some((rule_id, rewritten)) = result {
2674            assert_eq!(rule_id, rule_ids::T002);
2675            assert_eq!(rewritten, "@header(\"x\") != null");
2676        }
2677    }
2678
2679    #[test]
2680    fn test_suggest_redundant_type_cast_len_to_number() {
2681        // @len returns UInt, :number is numeric-compatible → redundant
2682        let expr = "@len(.items):number >= 0";
2683        let signatures = plugin_signatures();
2684        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2685        assert!(
2686            result.is_some(),
2687            "Expected redundant cast for @len(:number)"
2688        );
2689        if let Some((_, rewritten)) = result {
2690            assert_eq!(rewritten, "@len(.items) >= 0");
2691        }
2692    }
2693
2694    #[test]
2695    fn test_suggest_non_redundant_type_cast_number() {
2696        // .price:number is NOT redundant because .price is Any
2697        let expr = ".price:number >= 0";
2698        let signatures = plugin_signatures();
2699        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2700        assert!(
2701            result.is_none(),
2702            "Should not flag .price:number as redundant"
2703        );
2704    }
2705
2706    #[test]
2707    fn test_suggest_non_redundant_type_cast_string() {
2708        // .name:string is NOT redundant because .name is Any
2709        let expr = ".name:string contains \"hello\"";
2710        let signatures = plugin_signatures();
2711        let result = suggest_redundant_type_cast(expr, signatures, OptimizeLevel::Advisory);
2712        assert!(
2713            result.is_none(),
2714            "Should not flag .name:string as redundant"
2715        );
2716    }
2717
2718    #[test]
2719    fn test_collect_redundant_type_cast_optimization() {
2720        let content = r#"--- ENDPOINT ---
2721test.Service/Method
2722
2723--- ASSERTS ---
2724@len(.items):uint >= 0
2725"#;
2726        let doc = parser::parse_gctf_from_str(content, "test.gctf").unwrap();
2727        let hints = collect_assertion_optimizations(&doc, OptimizeLevel::Advisory);
2728        assert!(!hints.is_empty(), "Expected at least one optimization hint");
2729        assert_eq!(hints[0].rule_id, rule_ids::T002);
2730        assert_eq!(hints[0].after, "@len(.items) >= 0");
2731    }
2732}