Skip to content

Commit c7462ff

Browse files
authored
Merge pull request #5 from gripmock/optimize-pipeline
[v1.4] grpctestify-optimizer
2 parents 3278c5e + 3b7e667 commit c7462ff

35 files changed

+3599
-986
lines changed

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
resolver = "2"
33

44
[workspace.package]
5-
version = "1.3.6"
5+
version = "1.4.0"
66
edition = "2024"
77
authors = ["bavix"]
88
license = "MIT"

src/assert/engine.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,13 @@ impl AssertionEngine {
7272
let trimmed = assertion.trim();
7373

7474
// 1. Try operators engine (handles @ functions and custom operators)
75-
match operators::evaluate_legacy(&self.plugin_manager, trimmed, response, headers, trailers)
76-
{
75+
match operators::evaluate_assertion(
76+
&self.plugin_manager,
77+
trimmed,
78+
response,
79+
headers,
80+
trailers,
81+
) {
7782
Ok(AssertionResult::Error(msg)) if msg.starts_with("Unsupported assertion syntax") => {
7883
// Fallback to JQ
7984
self.evaluate_jaq(trimmed, response)
@@ -298,6 +303,21 @@ mod tests {
298303
}
299304
}
300305

306+
#[test]
307+
fn test_evaluate_empty_plugin() {
308+
let engine = AssertionEngine::new();
309+
let response = json!({"tags": []});
310+
311+
let result = engine
312+
.evaluate("@empty(.tags)", &response, None, None)
313+
.unwrap();
314+
if let AssertionResult::Pass = result {
315+
// Pass
316+
} else {
317+
panic!("Expected Pass for empty value");
318+
}
319+
}
320+
301321
#[test]
302322
fn test_evaluate_equality_operator() {
303323
let engine = AssertionEngine::new();

src/assert/operators.rs

Lines changed: 154 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ use serde_json::Value;
77
use std::collections::HashMap;
88

99
use crate::assert::engine::AssertionResult;
10-
use crate::plugins::{PluginContext, PluginManager, PluginResult};
10+
use crate::plugins::{PluginContext, PluginManager, PluginResult, normalize_plugin_name};
1111

12-
/// Evaluate legacy assertion (plugins and operators)
13-
pub fn evaluate_legacy(
12+
/// Evaluate assertion expression (plugins and operators)
13+
pub fn evaluate_assertion(
1414
plugin_manager: &PluginManager,
1515
assertion: &str,
1616
response: &Value,
@@ -85,31 +85,18 @@ fn evaluate_boolean_function(
8585
) -> Result<AssertionResult> {
8686
if let (Some(start_paren), Some(end_paren)) = (expr.find('('), expr.rfind(')')) {
8787
let func_name = &expr[0..start_paren];
88-
let plugin_name = func_name.strip_prefix('@').unwrap_or(func_name);
8988
let arg_str = &expr[start_paren + 1..end_paren];
9089

91-
if let Some(plugin) = plugin_manager.get(plugin_name) {
90+
let resolved_name = normalize_plugin_name(func_name);
91+
92+
if let Some(plugin) = plugin_manager.get(resolved_name) {
9293
let context = PluginContext {
9394
response,
9495
headers,
9596
trailers,
9697
};
9798

98-
// Special handling for @header, @has_header, and @trailer arguments (raw string)
99-
let args = if plugin_name == "header"
100-
|| plugin_name == "has_header"
101-
|| plugin_name == "trailer"
102-
{
103-
vec![Value::String(arg_str.trim().trim_matches('"').to_string())]
104-
} else {
105-
vec![evaluate_expression(
106-
plugin_manager,
107-
arg_str,
108-
response,
109-
headers,
110-
trailers,
111-
)]
112-
};
99+
let args = parse_plugin_arguments(plugin_manager, arg_str, response, headers, trailers);
113100

114101
return match plugin.execute(&args, &context) {
115102
Ok(PluginResult::Assertion(res)) => Ok(res),
@@ -119,7 +106,7 @@ fn evaluate_boolean_function(
119106
} else {
120107
Ok(AssertionResult::fail(format!(
121108
"Plugin {} returned falsy value: {:?}",
122-
plugin_name, val
109+
resolved_name, val
123110
)))
124111
}
125112
}
@@ -144,31 +131,18 @@ fn evaluate_expression(
144131
&& let (Some(start_paren), Some(end_paren)) = (expr.find('('), expr.rfind(')'))
145132
{
146133
let func_name = &expr[0..start_paren];
147-
let plugin_name = func_name.strip_prefix('@').unwrap_or(func_name);
148134
let arg_str = &expr[start_paren + 1..end_paren];
149135

150-
if let Some(plugin) = plugin_manager.get(plugin_name) {
136+
let resolved_name = normalize_plugin_name(func_name);
137+
138+
if let Some(plugin) = plugin_manager.get(resolved_name) {
151139
let context = PluginContext {
152140
response,
153141
headers,
154142
trailers,
155143
};
156144

157-
// Special handling for @header, @has_header, and @trailer arguments (raw string)
158-
let args = if plugin_name == "header"
159-
|| plugin_name == "has_header"
160-
|| plugin_name == "trailer"
161-
{
162-
vec![Value::String(arg_str.trim().trim_matches('"').to_string())]
163-
} else {
164-
vec![evaluate_expression(
165-
plugin_manager,
166-
arg_str,
167-
response,
168-
headers,
169-
trailers,
170-
)]
171-
};
145+
let args = parse_plugin_arguments(plugin_manager, arg_str, response, headers, trailers);
172146

173147
match plugin.execute(&args, &context) {
174148
Ok(PluginResult::Value(v)) => return v,
@@ -196,6 +170,99 @@ fn parse_value(s: &str) -> Value {
196170
}
197171
}
198172

173+
fn parse_plugin_arguments(
174+
plugin_manager: &PluginManager,
175+
arg_str: &str,
176+
response: &Value,
177+
headers: Option<&HashMap<String, String>>,
178+
trailers: Option<&HashMap<String, String>>,
179+
) -> Vec<Value> {
180+
split_arguments(arg_str)
181+
.into_iter()
182+
.map(|token| parse_argument_value(plugin_manager, token, response, headers, trailers))
183+
.collect()
184+
}
185+
186+
fn split_arguments(input: &str) -> Vec<&str> {
187+
let trimmed = input.trim();
188+
if trimmed.is_empty() {
189+
return Vec::new();
190+
}
191+
192+
let mut out = Vec::new();
193+
let mut start = 0;
194+
let mut depth = 0;
195+
let mut in_string = false;
196+
let mut escaped = false;
197+
198+
for (idx, ch) in trimmed.char_indices() {
199+
if in_string {
200+
if escaped {
201+
escaped = false;
202+
continue;
203+
}
204+
if ch == '\\' {
205+
escaped = true;
206+
continue;
207+
}
208+
if ch == '"' {
209+
in_string = false;
210+
}
211+
continue;
212+
}
213+
214+
match ch {
215+
'"' => in_string = true,
216+
'(' | '[' | '{' => depth += 1,
217+
')' | ']' | '}' => {
218+
if depth > 0 {
219+
depth -= 1;
220+
}
221+
}
222+
',' if depth == 0 => {
223+
out.push(trimmed[start..idx].trim());
224+
start = idx + 1;
225+
}
226+
_ => {}
227+
}
228+
}
229+
230+
out.push(trimmed[start..].trim());
231+
out
232+
}
233+
234+
fn parse_argument_value(
235+
plugin_manager: &PluginManager,
236+
token: &str,
237+
response: &Value,
238+
headers: Option<&HashMap<String, String>>,
239+
trailers: Option<&HashMap<String, String>>,
240+
) -> Value {
241+
let t = token.trim();
242+
if t.is_empty() {
243+
return Value::Null;
244+
}
245+
246+
if t.starts_with('@') && t.contains('(') && t.ends_with(')') {
247+
return evaluate_expression(plugin_manager, t, response, headers, trailers);
248+
}
249+
250+
if t == "." || t.starts_with('.') {
251+
return resolve_path(t, response);
252+
}
253+
254+
if (t.starts_with('"') && t.ends_with('"') && t.len() >= 2)
255+
|| t == "true"
256+
|| t == "false"
257+
|| t == "null"
258+
|| t.parse::<f64>().is_ok()
259+
{
260+
return parse_value(t);
261+
}
262+
263+
Value::String(t.to_string())
264+
}
265+
199266
fn compare(
200267
lhs: Value,
201268
op: &str,
@@ -343,34 +410,75 @@ mod tests {
343410
}
344411

345412
#[test]
346-
fn test_evaluate_legacy_equality() {
413+
fn test_evaluate_assertion_equality() {
347414
let pm = create_plugin_manager();
348415
let response = json!({"status": "success"});
349-
let result = evaluate_legacy(&pm, ".status == \"success\"", &response, None, None).unwrap();
416+
let result =
417+
evaluate_assertion(&pm, ".status == \"success\"", &response, None, None).unwrap();
350418
assert!(matches!(result, AssertionResult::Pass));
351419
}
352420

353421
#[test]
354-
fn test_evaluate_legacy_inequality() {
422+
fn test_evaluate_assertion_inequality() {
355423
let pm = create_plugin_manager();
356424
let response = json!({"status": "success"});
357-
let result = evaluate_legacy(&pm, ".status == \"error\"", &response, None, None).unwrap();
425+
let result =
426+
evaluate_assertion(&pm, ".status == \"error\"", &response, None, None).unwrap();
358427
assert!(matches!(result, AssertionResult::Fail { .. }));
359428
}
360429

361430
#[test]
362-
fn test_evaluate_legacy_contains() {
431+
fn test_evaluate_assertion_contains() {
363432
let pm = create_plugin_manager();
364433
let response = json!({"name": "test"});
365-
let result = evaluate_legacy(&pm, ".name contains \"te\"", &response, None, None).unwrap();
434+
let result =
435+
evaluate_assertion(&pm, ".name contains \"te\"", &response, None, None).unwrap();
366436
assert!(matches!(result, AssertionResult::Pass));
367437
}
368438

369439
#[test]
370-
fn test_evaluate_legacy_plugin() {
440+
fn test_evaluate_assertion_plugin() {
371441
let pm = create_plugin_manager();
372442
let response = json!({"id": "550e8400-e29b-41d4-a716-446655440000"});
373-
let result = evaluate_legacy(&pm, "@uuid(.id)", &response, None, None).unwrap();
443+
let result = evaluate_assertion(&pm, "@uuid(.id)", &response, None, None).unwrap();
444+
assert!(matches!(result, AssertionResult::Pass));
445+
}
446+
447+
#[test]
448+
fn test_evaluate_assertion_has_header_unquoted_argument() {
449+
let pm = create_plugin_manager();
450+
let response = json!({});
451+
let mut headers = HashMap::new();
452+
headers.insert("content-type".to_string(), "application/json".to_string());
453+
454+
let result = evaluate_assertion(
455+
&pm,
456+
"@has_header(content-type) == true",
457+
&response,
458+
Some(&headers),
459+
None,
460+
)
461+
.unwrap();
462+
463+
assert!(matches!(result, AssertionResult::Pass));
464+
}
465+
466+
#[test]
467+
fn test_evaluate_assertion_trailer_value_plugin() {
468+
let pm = create_plugin_manager();
469+
let response = json!({});
470+
let mut trailers = HashMap::new();
471+
trailers.insert("grpc-status".to_string(), "0".to_string());
472+
473+
let result = evaluate_assertion(
474+
&pm,
475+
"@trailer(\"grpc-status\") == \"0\"",
476+
&response,
477+
None,
478+
Some(&trailers),
479+
)
480+
.unwrap();
481+
374482
assert!(matches!(result, AssertionResult::Pass));
375483
}
376484

src/cli/args.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,10 @@ pub struct FmtArgs {
248248
/// Write changes to file instead of stdout
249249
#[arg(short = 'w', long, default_value_t = false)]
250250
pub write: bool,
251+
252+
/// Apply safe optimizer rewrites before formatting
253+
#[arg(short = 'o', long = "optimize", default_value_t = false)]
254+
pub optimize: bool,
251255
}
252256

253257
impl Cli {
@@ -314,3 +318,37 @@ impl Cli {
314318
}
315319
}
316320
}
321+
322+
fn is_json_format(value: &str) -> bool {
323+
value.eq_ignore_ascii_case("json")
324+
}
325+
326+
impl ListArgs {
327+
pub fn is_json(&self) -> bool {
328+
is_json_format(&self.format)
329+
}
330+
}
331+
332+
impl InspectArgs {
333+
pub fn is_json(&self) -> bool {
334+
is_json_format(&self.format)
335+
}
336+
}
337+
338+
impl ExplainArgs {
339+
pub fn is_json(&self) -> bool {
340+
is_json_format(&self.format)
341+
}
342+
}
343+
344+
impl CheckArgs {
345+
pub fn is_json(&self) -> bool {
346+
is_json_format(&self.format)
347+
}
348+
}
349+
350+
impl RunArgs {
351+
pub fn is_json_coverage(&self) -> bool {
352+
is_json_format(&self.coverage_format)
353+
}
354+
}

0 commit comments

Comments
 (0)