1use crate::config::E2eConfig;
19use crate::escape::{escape_shell, sanitize_filename, sanitize_ident};
20use crate::field_access::FieldResolver;
21use crate::fixture::{Assertion, Fixture, FixtureGroup};
22use alef_core::backend::GeneratedFile;
23use alef_core::config::ResolvedCrateConfig;
24use alef_core::hash::{self, CommentStyle};
25use anyhow::Result;
26use std::fmt::Write as FmtWrite;
27use std::path::PathBuf;
28
29use super::E2eCodegen;
30
31pub struct BrewCodegen;
33
34impl E2eCodegen for BrewCodegen {
35 fn generate(
36 &self,
37 groups: &[FixtureGroup],
38 e2e_config: &E2eConfig,
39 _config: &ResolvedCrateConfig,
40 _type_defs: &[alef_core::ir::TypeDef],
41 _enums: &[alef_core::ir::EnumDef],
42 ) -> Result<Vec<GeneratedFile>> {
43 let lang = self.language_name();
44 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
45
46 let call = &e2e_config.call;
48 let overrides = call.overrides.get(lang);
49 let subcommand = overrides
50 .and_then(|o| o.function.as_ref())
51 .cloned()
52 .unwrap_or_else(|| call.function.clone());
53
54 let static_cli_args: Vec<String> = overrides.map(|o| o.cli_args.clone()).unwrap_or_default();
56
57 let cli_flags: std::collections::HashMap<String, String> =
59 overrides.map(|o| o.cli_flags.clone()).unwrap_or_default();
60
61 let binary_name = e2e_config
63 .registry
64 .packages
65 .get(lang)
66 .and_then(|p| p.name.as_ref())
67 .cloned()
68 .or_else(|| e2e_config.packages.get(lang).and_then(|p| p.name.as_ref()).cloned())
69 .unwrap_or_else(|| call.module.clone());
70
71 let active_groups: Vec<(&FixtureGroup, Vec<&Fixture>)> = groups
73 .iter()
74 .filter_map(|group| {
75 let active: Vec<&Fixture> = group
76 .fixtures
77 .iter()
78 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
79 .collect();
80 if active.is_empty() { None } else { Some((group, active)) }
81 })
82 .collect();
83
84 let mut files = Vec::new();
85
86 let category_names: Vec<String> = active_groups
88 .iter()
89 .map(|(g, _)| sanitize_filename(&g.category))
90 .collect();
91 files.push(GeneratedFile {
92 path: output_base.join("run_tests.sh"),
93 content: render_run_tests(&category_names),
94 generated_header: true,
95 });
96
97 for (group, active) in &active_groups {
99 let safe_category = sanitize_filename(&group.category);
100 let filename = format!("test_{safe_category}.sh");
101 let content = render_category_file(
102 &group.category,
103 active,
104 &binary_name,
105 &subcommand,
106 &static_cli_args,
107 &cli_flags,
108 &e2e_config.call.args,
109 e2e_config,
110 );
111 files.push(GeneratedFile {
112 path: output_base.join(filename),
113 content,
114 generated_header: true,
115 });
116 }
117
118 Ok(files)
119 }
120
121 fn language_name(&self) -> &'static str {
122 "brew"
123 }
124}
125
126fn render_run_tests(categories: &[String]) -> String {
128 let mut out = String::new();
129 let _ = writeln!(out, "#!/usr/bin/env bash");
130 out.push_str(&hash::header(CommentStyle::Hash));
131 let _ = writeln!(out, "# shellcheck disable=SC1091");
132 let _ = writeln!(out, "set -euo pipefail");
133 let _ = writeln!(out);
134 let _ = writeln!(out, "# MOCK_SERVER_URL must be set to the base URL of the mock server.");
135 let _ = writeln!(out, ": \"${{MOCK_SERVER_URL:?MOCK_SERVER_URL is required}}\"");
136 let _ = writeln!(out);
137 let _ = writeln!(out, "# Verify that jq is available.");
138 let _ = writeln!(out, "if ! command -v jq &>/dev/null; then");
139 let _ = writeln!(out, " echo 'error: jq is required but not found in PATH' >&2");
140 let _ = writeln!(out, " exit 1");
141 let _ = writeln!(out, "fi");
142 let _ = writeln!(out);
143 let _ = writeln!(out, "PASS=0");
144 let _ = writeln!(out, "FAIL=0");
145 let _ = writeln!(out);
146
147 let _ = writeln!(out, "assert_equals() {{");
149 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
150 let _ = writeln!(out, " if [ \"$actual\" != \"$expected\" ]; then");
151 let _ = writeln!(
152 out,
153 " echo \"FAIL [$label]: expected '$expected', got '$actual'\" >&2"
154 );
155 let _ = writeln!(out, " return 1");
156 let _ = writeln!(out, " fi");
157 let _ = writeln!(out, "}}");
158 let _ = writeln!(out);
159 let _ = writeln!(out, "assert_contains() {{");
160 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
161 let _ = writeln!(out, " if [[ \"$actual\" != *\"$expected\"* ]]; then");
162 let _ = writeln!(
163 out,
164 " echo \"FAIL [$label]: expected to contain '$expected'\" >&2"
165 );
166 let _ = writeln!(out, " return 1");
167 let _ = writeln!(out, " fi");
168 let _ = writeln!(out, "}}");
169 let _ = writeln!(out);
170 let _ = writeln!(out, "assert_not_empty() {{");
171 let _ = writeln!(out, " local actual=\"$1\" label=\"$2\"");
172 let _ = writeln!(out, " if [ -z \"$actual\" ]; then");
173 let _ = writeln!(out, " echo \"FAIL [$label]: expected non-empty value\" >&2");
174 let _ = writeln!(out, " return 1");
175 let _ = writeln!(out, " fi");
176 let _ = writeln!(out, "}}");
177 let _ = writeln!(out);
178 let _ = writeln!(out, "assert_count_min() {{");
179 let _ = writeln!(out, " local count=\"$1\" min=\"$2\" label=\"$3\"");
180 let _ = writeln!(out, " if [ \"$count\" -lt \"$min\" ]; then");
181 let _ = writeln!(
182 out,
183 " echo \"FAIL [$label]: expected at least $min elements, got $count\" >&2"
184 );
185 let _ = writeln!(out, " return 1");
186 let _ = writeln!(out, " fi");
187 let _ = writeln!(out, "}}");
188 let _ = writeln!(out);
189 let _ = writeln!(out, "assert_greater_than() {{");
190 let _ = writeln!(out, " local val=\"$1\" threshold=\"$2\" label=\"$3\"");
191 let _ = writeln!(
192 out,
193 " if [ \"$(echo \"$val > $threshold\" | bc -l)\" != \"1\" ]; then"
194 );
195 let _ = writeln!(out, " echo \"FAIL [$label]: expected $val > $threshold\" >&2");
196 let _ = writeln!(out, " return 1");
197 let _ = writeln!(out, " fi");
198 let _ = writeln!(out, "}}");
199 let _ = writeln!(out);
200 let _ = writeln!(out, "assert_greater_than_or_equal() {{");
201 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
202 let _ = writeln!(out, " if [ \"$actual\" -lt \"$expected\" ]; then");
203 let _ = writeln!(out, " echo \"FAIL [$label]: expected $actual >= $expected\" >&2");
204 let _ = writeln!(out, " return 1");
205 let _ = writeln!(out, " fi");
206 let _ = writeln!(out, "}}");
207 let _ = writeln!(out);
208 let _ = writeln!(out, "assert_is_empty() {{");
209 let _ = writeln!(out, " local actual=\"$1\" label=\"$2\"");
210 let _ = writeln!(out, " if [ -n \"$actual\" ]; then");
211 let _ = writeln!(
212 out,
213 " echo \"FAIL [$label]: expected empty value, got '$actual'\" >&2"
214 );
215 let _ = writeln!(out, " return 1");
216 let _ = writeln!(out, " fi");
217 let _ = writeln!(out, "}}");
218 let _ = writeln!(out);
219 let _ = writeln!(out, "assert_less_than() {{");
220 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
221 let _ = writeln!(out, " if [ \"$actual\" -ge \"$expected\" ]; then");
222 let _ = writeln!(out, " echo \"FAIL [$label]: expected $actual < $expected\" >&2");
223 let _ = writeln!(out, " return 1");
224 let _ = writeln!(out, " fi");
225 let _ = writeln!(out, "}}");
226 let _ = writeln!(out);
227 let _ = writeln!(out, "assert_less_than_or_equal() {{");
228 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
229 let _ = writeln!(out, " if [ \"$actual\" -gt \"$expected\" ]; then");
230 let _ = writeln!(out, " echo \"FAIL [$label]: expected $actual <= $expected\" >&2");
231 let _ = writeln!(out, " return 1");
232 let _ = writeln!(out, " fi");
233 let _ = writeln!(out, "}}");
234 let _ = writeln!(out);
235 let _ = writeln!(out, "assert_not_contains() {{");
236 let _ = writeln!(out, " local actual=\"$1\" expected=\"$2\" label=\"$3\"");
237 let _ = writeln!(out, " if [[ \"$actual\" == *\"$expected\"* ]]; then");
238 let _ = writeln!(
239 out,
240 " echo \"FAIL [$label]: expected not to contain '$expected'\" >&2"
241 );
242 let _ = writeln!(out, " return 1");
243 let _ = writeln!(out, " fi");
244 let _ = writeln!(out, "}}");
245 let _ = writeln!(out);
246
247 let script_dir = r#"SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)""#;
249 let _ = writeln!(out, "{script_dir}");
250 let _ = writeln!(out);
251 for category in categories {
252 let _ = writeln!(out, "# shellcheck source=test_{category}.sh");
253 let _ = writeln!(out, "source \"$SCRIPT_DIR/test_{category}.sh\"");
254 }
255 let _ = writeln!(out);
256
257 let _ = writeln!(out, "run_test() {{");
259 let _ = writeln!(out, " local name=\"$1\"");
260 let _ = writeln!(out, " if \"$name\"; then");
261 let _ = writeln!(out, " echo \"PASS: $name\"");
262 let _ = writeln!(out, " PASS=$((PASS + 1))");
263 let _ = writeln!(out, " else");
264 let _ = writeln!(out, " echo \"FAIL: $name\"");
265 let _ = writeln!(out, " FAIL=$((FAIL + 1))");
266 let _ = writeln!(out, " fi");
267 let _ = writeln!(out, "}}");
268 let _ = writeln!(out);
269
270 let _ = writeln!(out, "# Run all generated test functions.");
274 for category in categories {
275 let _ = writeln!(out, "# Category: {category}");
276 let _ = writeln!(out, "run_tests_{category}");
279 }
280 let _ = writeln!(out);
281 let _ = writeln!(out, "echo \"\"");
282 let _ = writeln!(out, "echo \"Results: $PASS passed, $FAIL failed\"");
283 let _ = writeln!(out, "[ \"$FAIL\" -eq 0 ]");
284 out
285}
286
287#[allow(clippy::too_many_arguments)]
289fn render_category_file(
290 category: &str,
291 fixtures: &[&Fixture],
292 binary_name: &str,
293 subcommand: &str,
294 static_cli_args: &[String],
295 cli_flags: &std::collections::HashMap<String, String>,
296 args: &[crate::config::ArgMapping],
297 e2e_config: &E2eConfig,
298) -> String {
299 let safe_category = sanitize_filename(category);
300 let mut out = String::new();
301 let _ = writeln!(out, "#!/usr/bin/env bash");
302 out.push_str(&hash::header(CommentStyle::Hash));
303 let _ = writeln!(out, "# E2e tests for category: {category}");
304 let _ = writeln!(out, "set -euo pipefail");
305 let _ = writeln!(out);
306
307 for fixture in fixtures {
308 render_test_function(
309 &mut out,
310 fixture,
311 binary_name,
312 subcommand,
313 static_cli_args,
314 cli_flags,
315 args,
316 e2e_config,
317 );
318 let _ = writeln!(out);
319 }
320
321 let _ = writeln!(out, "run_tests_{safe_category}() {{");
323 for fixture in fixtures {
324 let fn_name = sanitize_ident(&fixture.id);
325 let _ = writeln!(out, " run_test test_{fn_name}");
326 }
327 let _ = writeln!(out, "}}");
328 out
329}
330
331#[allow(clippy::too_many_arguments)]
333fn render_test_function(
334 out: &mut String,
335 fixture: &Fixture,
336 binary_name: &str,
337 subcommand: &str,
338 static_cli_args: &[String],
339 cli_flags: &std::collections::HashMap<String, String>,
340 _args: &[crate::config::ArgMapping],
341 e2e_config: &E2eConfig,
342) {
343 let fn_name = sanitize_ident(&fixture.id);
344 let description = &fixture.description;
345
346 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
347
348 let _ = writeln!(out, "test_{fn_name}() {{");
349 let _ = writeln!(out, " # {description}");
350
351 let call_config = e2e_config.resolve_call_for_fixture(
353 fixture.call.as_deref(),
354 &fixture.id,
355 &fixture.resolved_category(),
356 &fixture.tags,
357 &fixture.input,
358 );
359 let call_field_resolver = FieldResolver::new(
360 e2e_config.effective_fields(call_config),
361 e2e_config.effective_fields_optional(call_config),
362 e2e_config.effective_result_fields(call_config),
363 e2e_config.effective_fields_array(call_config),
364 &std::collections::HashSet::new(),
365 );
366 let field_resolver = &call_field_resolver;
367
368 let cmd_parts = build_cli_command(
370 fixture,
371 binary_name,
372 subcommand,
373 static_cli_args,
374 cli_flags,
375 &call_config.args,
376 );
377
378 if expects_error {
379 let cmd = cmd_parts.join(" ");
380 let _ = writeln!(out, " if {cmd} >/dev/null 2>&1; then");
381 let _ = writeln!(
382 out,
383 " echo 'FAIL [error]: expected command to fail but it succeeded' >&2"
384 );
385 let _ = writeln!(out, " return 1");
386 let _ = writeln!(out, " fi");
387 let _ = writeln!(out, "}}");
388 return;
389 }
390
391 let has_active_assertions = fixture.assertions.iter().any(|a| {
393 a.field
394 .as_ref()
395 .is_none_or(|f| f.is_empty() || field_resolver.is_valid_for_result(f))
396 });
397
398 let cmd = cmd_parts.join(" ");
400 if has_active_assertions {
401 let _ = writeln!(out, " local output");
402 let _ = writeln!(out, " output=$({cmd})");
403 } else {
404 let _ = writeln!(out, " {cmd} >/dev/null");
405 }
406 let _ = writeln!(out);
407
408 for assertion in &fixture.assertions {
410 render_assertion(out, assertion, binary_name, field_resolver);
411 }
412
413 let _ = writeln!(out, "}}");
414}
415
416fn build_cli_command(
421 fixture: &Fixture,
422 binary_name: &str,
423 subcommand: &str,
424 static_cli_args: &[String],
425 cli_flags: &std::collections::HashMap<String, String>,
426 args: &[crate::config::ArgMapping],
427) -> Vec<String> {
428 let mut parts: Vec<String> = vec![binary_name.to_string(), subcommand.to_string()];
429
430 for arg in args {
431 match arg.arg_type.as_str() {
432 "mock_url" => {
433 let upper_id = fixture.id.to_uppercase();
440 parts.push(format!(
441 "\"${{MOCK_SERVER_{upper_id}:-${{MOCK_SERVER_URL}}/fixtures/{}}}\"",
442 fixture.id
443 ));
444 }
445 "handle" => {
446 }
448 _ => {
449 if let Some(flag) = cli_flags.get(&arg.field) {
451 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
452 if let Some(val) = fixture.input.get(field) {
453 if !val.is_null() {
454 let val_str = json_value_to_shell_arg(val);
455 parts.push(flag.clone());
456 parts.push(val_str);
457 }
458 }
459 }
460 }
461 }
462 }
463
464 if let Some(config_val) = fixture.input.get("config") {
466 if !config_val.is_null() {
467 let config_json = serde_json::to_string(config_val).unwrap_or_default();
469 parts.push("--config".to_string());
470 parts.push(format!("'{}'", escape_shell(&config_json)));
471 }
472 }
473
474 for static_arg in static_cli_args {
476 parts.push(static_arg.clone());
477 }
478
479 parts
480}
481
482fn json_value_to_shell_arg(value: &serde_json::Value) -> String {
487 match value {
488 serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
489 serde_json::Value::Bool(b) => b.to_string(),
490 serde_json::Value::Number(n) => n.to_string(),
491 serde_json::Value::Null => "''".to_string(),
492 other => format!("'{}'", escape_shell(&other.to_string())),
493 }
494}
495
496fn field_to_jq_path(resolved: &str) -> String {
503 if let Some((prefix, suffix)) = resolved.rsplit_once('.') {
506 if suffix == "length" || suffix == "count" || suffix == "size" {
507 return format!(".{prefix} | length");
508 }
509 }
510 if resolved == "length" || resolved == "count" || resolved == "size" {
512 return ". | length".to_string();
513 }
514 format!(".{resolved}")
515}
516
517fn build_brew_method_call(binary_name: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
524 let subcommand = method_name.replace('_', "-");
525 if let Some(args_val) = args {
526 let arg_str = args_val
527 .as_object()
528 .map(|obj| {
529 obj.values()
530 .map(|v| match v {
531 serde_json::Value::String(s) => format!("'{}'", escape_shell(s)),
532 other => other.to_string(),
533 })
534 .collect::<Vec<_>>()
535 .join(" ")
536 })
537 .unwrap_or_default();
538 if arg_str.is_empty() {
539 format!("{binary_name} {subcommand} \"$output\"")
540 } else {
541 format!("{binary_name} {subcommand} \"$output\" {arg_str}")
542 }
543 } else {
544 format!("{binary_name} {subcommand} \"$output\"")
545 }
546}
547
548fn render_assertion(out: &mut String, assertion: &Assertion, binary_name: &str, field_resolver: &FieldResolver) {
550 if let Some(f) = &assertion.field {
552 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
553 let _ = writeln!(out, " # skipped: field '{f}' not available on result type");
554 return;
555 }
556 }
557
558 match assertion.assertion_type.as_str() {
559 "equals" => {
560 if let Some(field) = &assertion.field {
561 if let Some(expected) = &assertion.value {
562 let resolved = field_resolver.resolve(field);
563 let jq_path = field_to_jq_path(resolved);
564 let expected_str = json_value_to_shell_string(expected);
565 let safe_field = sanitize_ident(field);
566 let _ = writeln!(out, " local val_{safe_field}");
567 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
568 let _ = writeln!(
569 out,
570 " assert_equals \"$val_{safe_field}\" '{expected_str}' '{field}'"
571 );
572 }
573 }
574 }
575 "contains" => {
576 if let Some(field) = &assertion.field {
577 if let Some(expected) = &assertion.value {
578 let resolved = field_resolver.resolve(field);
579 let jq_path = field_to_jq_path(resolved);
580 let expected_str = json_value_to_shell_string(expected);
581 let safe_field = sanitize_ident(field);
582 let _ = writeln!(out, " local val_{safe_field}");
583 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
584 let _ = writeln!(
585 out,
586 " assert_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
587 );
588 }
589 }
590 }
591 "not_empty" | "tree_not_null" => {
592 if let Some(field) = &assertion.field {
593 let resolved = field_resolver.resolve(field);
594 let jq_path = field_to_jq_path(resolved);
595 let safe_field = sanitize_ident(field);
596 let _ = writeln!(out, " local val_{safe_field}");
597 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
598 let _ = writeln!(out, " assert_not_empty \"$val_{safe_field}\" '{field}'");
599 }
600 }
601 "count_min" | "root_child_count_min" => {
602 if let Some(field) = &assertion.field {
603 if let Some(val) = &assertion.value {
604 if let Some(min) = val.as_u64() {
605 let resolved = field_resolver.resolve(field);
606 let jq_path = field_to_jq_path(resolved);
607 let safe_field = sanitize_ident(field);
608 let _ = writeln!(out, " local count_{safe_field}");
609 let _ = writeln!(
610 out,
611 " count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
612 );
613 let _ = writeln!(out, " assert_count_min \"$count_{safe_field}\" {min} '{field}'");
614 }
615 }
616 }
617 }
618 "greater_than" => {
619 if let Some(field) = &assertion.field {
620 if let Some(val) = &assertion.value {
621 let resolved = field_resolver.resolve(field);
622 let jq_path = field_to_jq_path(resolved);
623 let threshold = json_value_to_shell_string(val);
624 let safe_field = sanitize_ident(field);
625 let _ = writeln!(out, " local val_{safe_field}");
626 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
627 let _ = writeln!(
628 out,
629 " assert_greater_than \"$val_{safe_field}\" '{threshold}' '{field}'"
630 );
631 }
632 }
633 }
634 "greater_than_or_equal" => {
635 if let Some(field) = &assertion.field {
636 if let Some(val) = &assertion.value {
637 let resolved = field_resolver.resolve(field);
638 let jq_path = field_to_jq_path(resolved);
639 let threshold = json_value_to_shell_string(val);
640 let safe_field = sanitize_ident(field);
641 let _ = writeln!(out, " local val_{safe_field}");
642 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
643 let _ = writeln!(
644 out,
645 " assert_greater_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
646 );
647 }
648 }
649 }
650 "contains_all" => {
651 if let Some(field) = &assertion.field {
652 if let Some(serde_json::Value::Array(items)) = &assertion.value {
653 let resolved = field_resolver.resolve(field);
654 let jq_path = field_to_jq_path(resolved);
655 let safe_field = sanitize_ident(field);
656 let _ = writeln!(out, " local val_{safe_field}");
657 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
658 for (index, item) in items.iter().enumerate() {
659 let item_str = json_value_to_shell_string(item);
660 let _ = writeln!(
661 out,
662 " assert_contains \"$val_{safe_field}\" '{item_str}' '{field}[{index}]'"
663 );
664 }
665 }
666 }
667 }
668 "is_empty" => {
669 if let Some(field) = &assertion.field {
670 let resolved = field_resolver.resolve(field);
671 let jq_path = field_to_jq_path(resolved);
672 let safe_field = sanitize_ident(field);
673 let _ = writeln!(out, " local val_{safe_field}");
674 let _ = writeln!(
676 out,
677 " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path} // empty')"
678 );
679 let _ = writeln!(out, " assert_is_empty \"$val_{safe_field}\" '{field}'");
680 }
681 }
682 "less_than" => {
683 if let Some(field) = &assertion.field {
684 if let Some(val) = &assertion.value {
685 let resolved = field_resolver.resolve(field);
686 let jq_path = field_to_jq_path(resolved);
687 let threshold = json_value_to_shell_string(val);
688 let safe_field = sanitize_ident(field);
689 let _ = writeln!(out, " local val_{safe_field}");
690 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
691 let _ = writeln!(
692 out,
693 " assert_less_than \"$val_{safe_field}\" '{threshold}' '{field}'"
694 );
695 }
696 }
697 }
698 "not_contains" => {
699 if let Some(field) = &assertion.field {
700 if let Some(expected) = &assertion.value {
701 let resolved = field_resolver.resolve(field);
702 let jq_path = field_to_jq_path(resolved);
703 let expected_str = json_value_to_shell_string(expected);
704 let safe_field = sanitize_ident(field);
705 let _ = writeln!(out, " local val_{safe_field}");
706 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
707 let _ = writeln!(
708 out,
709 " assert_not_contains \"$val_{safe_field}\" '{expected_str}' '{field}'"
710 );
711 }
712 }
713 }
714 "count_equals" => {
715 if let Some(field) = &assertion.field {
716 if let Some(val) = &assertion.value {
717 if let Some(n) = val.as_u64() {
718 let resolved = field_resolver.resolve(field);
719 let jq_path = field_to_jq_path(resolved);
720 let safe_field = sanitize_ident(field);
721 let _ = writeln!(out, " local count_{safe_field}");
722 let _ = writeln!(
723 out,
724 " count_{safe_field}=$(echo \"$output\" | jq '{jq_path} | length')"
725 );
726 let _ = writeln!(out, " [ \"$count_{safe_field}\" -eq {n} ] || exit 1");
727 }
728 }
729 }
730 }
731 "is_true" => {
732 if let Some(field) = &assertion.field {
733 let resolved = field_resolver.resolve(field);
734 let jq_path = field_to_jq_path(resolved);
735 let safe_field = sanitize_ident(field);
736 let _ = writeln!(out, " local val_{safe_field}");
737 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
738 let _ = writeln!(out, " [ \"$val_{safe_field}\" = \"true\" ] || exit 1");
739 }
740 }
741 "is_false" => {
742 if let Some(field) = &assertion.field {
743 let resolved = field_resolver.resolve(field);
744 let jq_path = field_to_jq_path(resolved);
745 let safe_field = sanitize_ident(field);
746 let _ = writeln!(out, " local val_{safe_field}");
747 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
748 let _ = writeln!(out, " [ \"$val_{safe_field}\" = \"false\" ] || exit 1");
749 }
750 }
751 "less_than_or_equal" => {
752 if let Some(field) = &assertion.field {
753 if let Some(val) = &assertion.value {
754 let resolved = field_resolver.resolve(field);
755 let jq_path = field_to_jq_path(resolved);
756 let threshold = json_value_to_shell_string(val);
757 let safe_field = sanitize_ident(field);
758 let _ = writeln!(out, " local val_{safe_field}");
759 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
760 let _ = writeln!(
761 out,
762 " assert_less_than_or_equal \"$val_{safe_field}\" '{threshold}' '{field}'"
763 );
764 }
765 }
766 }
767 "method_result" => {
768 if let Some(method_name) = &assertion.method {
769 let check = assertion.check.as_deref().unwrap_or("is_true");
770 let cmd = build_brew_method_call(binary_name, method_name, assertion.args.as_ref());
771 if check == "is_error" {
774 let _ = writeln!(out, " if {cmd} >/dev/null 2>&1; then");
775 let _ = writeln!(
776 out,
777 " echo 'FAIL [method_result]: expected method to raise error but it succeeded' >&2"
778 );
779 let _ = writeln!(out, " return 1");
780 let _ = writeln!(out, " fi");
781 } else {
782 let method_var = format!("method_result_{}", sanitize_ident(method_name));
783 let _ = writeln!(out, " local {method_var}");
784 let _ = writeln!(out, " {method_var}=$({cmd})");
785 match check {
786 "equals" => {
787 if let Some(val) = &assertion.value {
788 let expected = json_value_to_shell_string(val);
789 let _ = writeln!(out, " [ \"${method_var}\" = '{expected}' ] || exit 1");
790 }
791 }
792 "is_true" => {
793 let _ = writeln!(out, " [ \"${method_var}\" = \"true\" ] || exit 1");
794 }
795 "is_false" => {
796 let _ = writeln!(out, " [ \"${method_var}\" = \"false\" ] || exit 1");
797 }
798 "greater_than_or_equal" => {
799 if let Some(val) = &assertion.value {
800 if let Some(n) = val.as_u64() {
801 let _ = writeln!(out, " [ \"${method_var}\" -ge {n} ] || exit 1");
802 }
803 }
804 }
805 "count_min" => {
806 if let Some(val) = &assertion.value {
807 if let Some(n) = val.as_u64() {
808 let _ = writeln!(
809 out,
810 " local count_from_method_result=$(echo \"${method_var}\" | jq 'length')"
811 );
812 let _ = writeln!(out, " [ \"$count_from_method_result\" -ge {n} ] || exit 1");
813 }
814 }
815 }
816 "contains" => {
817 if let Some(val) = &assertion.value {
818 let expected = json_value_to_shell_string(val);
819 let _ = writeln!(out, " [[ \"${method_var}\" == *'{expected}'* ]] || exit 1");
820 }
821 }
822 other_check => {
823 panic!("Brew e2e generator: unsupported method_result check type: {other_check}");
824 }
825 }
826 }
827 } else {
828 panic!("method_result assertion missing 'method' field");
829 }
830 }
831 "min_length" => {
832 if let Some(field) = &assertion.field {
833 if let Some(val) = &assertion.value {
834 if let Some(n) = val.as_u64() {
835 let resolved = field_resolver.resolve(field);
836 let jq_path = field_to_jq_path(resolved);
837 let safe_field = sanitize_ident(field);
838 let _ = writeln!(out, " local val_{safe_field}");
839 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
840 let _ = writeln!(
841 out,
842 " [ \"${{#val_{safe_field}}}\" -ge {n} ] || {{ echo \"FAIL [{field}]: expected length >= {n}\" >&2; return 1; }}"
843 );
844 }
845 }
846 }
847 }
848 "max_length" => {
849 if let Some(field) = &assertion.field {
850 if let Some(val) = &assertion.value {
851 if let Some(n) = val.as_u64() {
852 let resolved = field_resolver.resolve(field);
853 let jq_path = field_to_jq_path(resolved);
854 let safe_field = sanitize_ident(field);
855 let _ = writeln!(out, " local val_{safe_field}");
856 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
857 let _ = writeln!(
858 out,
859 " [ \"${{#val_{safe_field}}}\" -le {n} ] || {{ echo \"FAIL [{field}]: expected length <= {n}\" >&2; return 1; }}"
860 );
861 }
862 }
863 }
864 }
865 "ends_with" => {
866 if let Some(field) = &assertion.field {
867 if let Some(expected) = &assertion.value {
868 let resolved = field_resolver.resolve(field);
869 let jq_path = field_to_jq_path(resolved);
870 let expected_str = json_value_to_shell_string(expected);
871 let safe_field = sanitize_ident(field);
872 let _ = writeln!(out, " local val_{safe_field}");
873 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
874 let _ = writeln!(
875 out,
876 " [[ \"$val_{safe_field}\" == *'{expected_str}' ]] || {{ echo \"FAIL [{field}]: expected to end with '{expected_str}'\" >&2; return 1; }}"
877 );
878 }
879 }
880 }
881 "matches_regex" => {
882 if let Some(field) = &assertion.field {
883 if let Some(expected) = &assertion.value {
884 if let Some(pattern) = expected.as_str() {
885 let resolved = field_resolver.resolve(field);
886 let jq_path = field_to_jq_path(resolved);
887 let safe_field = sanitize_ident(field);
888 let _ = writeln!(out, " local val_{safe_field}");
889 let _ = writeln!(out, " val_{safe_field}=$(echo \"$output\" | jq -r '{jq_path}')");
890 let _ = writeln!(
891 out,
892 " [[ \"$val_{safe_field}\" =~ {pattern} ]] || {{ echo \"FAIL [{field}]: expected to match /{pattern}/\" >&2; return 1; }}"
893 );
894 }
895 }
896 }
897 }
898 "not_error" => {
899 }
901 "error" => {
902 }
904 other => {
905 panic!("Brew e2e generator: unsupported assertion type: {other}");
906 }
907 }
908}
909
910fn json_value_to_shell_string(value: &serde_json::Value) -> String {
914 match value {
915 serde_json::Value::String(s) => escape_shell(s),
916 serde_json::Value::Bool(b) => b.to_string(),
917 serde_json::Value::Number(n) => n.to_string(),
918 serde_json::Value::Null => String::new(),
919 other => escape_shell(&other.to_string()),
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 fn assert_shfmt_canonical_indent(script: &str, context: &str) {
933 for (lineno, line) in script.lines().enumerate() {
934 let leading_spaces = line.chars().take_while(|c| *c == ' ').count();
935 assert!(
936 leading_spaces.is_multiple_of(2),
937 "{context}: line {lineno} has {leading_spaces}-space indent (must be a multiple of 2 for shfmt compatibility): {line:?}",
938 );
939 }
940 }
941
942 #[test]
943 fn render_run_tests_uses_two_space_indent() {
944 let categories = vec!["auth".to_string(), "crawl".to_string()];
945 let script = render_run_tests(&categories);
946 assert_shfmt_canonical_indent(&script, "render_run_tests");
947 assert!(
948 script.lines().any(|l| l.starts_with(" ") && !l.starts_with(" ")),
949 "render_run_tests should emit at least one 2-space-indented line; got:\n{script}",
950 );
951 }
952}