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