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