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