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