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
479static EXTRA_BOOLEAN_PLUGINS: std::sync::OnceLock<HashSet<String>> = std::sync::OnceLock::new();
484
485pub 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
590fn 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
642fn 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 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
876fn 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 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
994fn 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
1012fn 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
1026fn 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 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
1054fn 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
1094fn 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
1108fn 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 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 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
1150fn 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 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 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 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
1239fn 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
1277fn 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
1319fn 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 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 let cast_type = TypeInfo::parse_type_name(cast_type_name)?;
1347
1348 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 == TypeInfo::Any || inner_type == TypeInfo::Yaml || inner_type == TypeInfo::Json {
1355 return None;
1356 }
1357
1358 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 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
1382fn 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 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 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 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 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 let not_at_name = format!("!@{}", name);
1431 if let Some(rest) = trimmed.strip_prefix(¬_at_name)
1432 && rest.starts_with('(')
1433 && rest.ends_with(')')
1434 {
1435 let args = &rest[1..rest.len() - 1];
1436 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 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 ¤t,
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 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 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 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 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 #[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 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 #[test]
2185 fn test_boolean_identity_or() {
2186 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 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 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 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 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 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 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 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 let result =
2244 suggest_plugin_length_simplification("@len(.items) > 0", OptimizeLevel::Advisory);
2245 assert!(result.is_none());
2246
2247 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 assert_eq!(
2892 suggest_plugin_length_simplification(
2893 "@len(a) and @len(b) == 0",
2894 OptimizeLevel::Advisory
2895 ),
2896 None
2897 );
2898 assert_eq!(
2900 suggest_plugin_length_simplification("@len(.x) == 0", OptimizeLevel::Advisory),
2901 Some((rule_ids::P001, "@is_empty(.x)".to_string()))
2902 );
2903 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 assert_eq!(
2915 suggest_deprecated_plugin_rename("!@uuid(", plugin_signatures(), OptimizeLevel::Safe),
2916 None
2917 );
2918 }
2919}